diff --git a/.backportrc.json b/.backportrc.json index 2603eb2e2d444..731f49183dba5 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/.ci/es-snapshots/Jenkinsfile_verify_es b/.ci/es-snapshots/Jenkinsfile_verify_es index ade79f27e10e9..75b686abe845f 100644 --- a/.ci/es-snapshots/Jenkinsfile_verify_es +++ b/.ci/es-snapshots/Jenkinsfile_verify_es @@ -21,40 +21,46 @@ def SNAPSHOT_MANIFEST = "https://storage.googleapis.com/kibana-ci-es-snapshots-d kibanaPipeline(timeoutMinutes: 120) { catchErrors { - withEnv(["ES_SNAPSHOT_MANIFEST=${SNAPSHOT_MANIFEST}"]) { - parallel([ - 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), - 'x-pack-intake-agent': workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh'), - 'kibana-oss-agent': workers.functional('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ - 'oss-ciGroup1': kibanaPipeline.ossCiGroupProcess(1), - 'oss-ciGroup2': kibanaPipeline.ossCiGroupProcess(2), - 'oss-ciGroup3': kibanaPipeline.ossCiGroupProcess(3), - 'oss-ciGroup4': kibanaPipeline.ossCiGroupProcess(4), - 'oss-ciGroup5': kibanaPipeline.ossCiGroupProcess(5), - 'oss-ciGroup6': kibanaPipeline.ossCiGroupProcess(6), - 'oss-ciGroup7': kibanaPipeline.ossCiGroupProcess(7), - 'oss-ciGroup8': kibanaPipeline.ossCiGroupProcess(8), - 'oss-ciGroup9': kibanaPipeline.ossCiGroupProcess(9), - 'oss-ciGroup10': kibanaPipeline.ossCiGroupProcess(10), - 'oss-ciGroup11': kibanaPipeline.ossCiGroupProcess(11), - 'oss-ciGroup12': kibanaPipeline.ossCiGroupProcess(12), - ]), - 'kibana-xpack-agent': workers.functional('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ - 'xpack-ciGroup1': kibanaPipeline.xpackCiGroupProcess(1), - 'xpack-ciGroup2': kibanaPipeline.xpackCiGroupProcess(2), - 'xpack-ciGroup3': kibanaPipeline.xpackCiGroupProcess(3), - 'xpack-ciGroup4': kibanaPipeline.xpackCiGroupProcess(4), - 'xpack-ciGroup5': kibanaPipeline.xpackCiGroupProcess(5), - 'xpack-ciGroup6': kibanaPipeline.xpackCiGroupProcess(6), - 'xpack-ciGroup7': kibanaPipeline.xpackCiGroupProcess(7), - 'xpack-ciGroup8': kibanaPipeline.xpackCiGroupProcess(8), - 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), - 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), - ]), - ]) - } + slackNotifications.onFailure( + title: ":broken_heart: *<${env.BUILD_URL}|[${SNAPSHOT_VERSION}] ES Snapshot Verification Failure>*", + message: ":broken_heart: [${SNAPSHOT_VERSION}] ES Snapshot Verification Failure", + ) { + retryable.enable(2) + withEnv(["ES_SNAPSHOT_MANIFEST=${SNAPSHOT_MANIFEST}"]) { + parallel([ + 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), + 'x-pack-intake-agent': workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh'), + 'kibana-oss-agent': workers.functional('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ + 'oss-ciGroup1': kibanaPipeline.ossCiGroupProcess(1), + 'oss-ciGroup2': kibanaPipeline.ossCiGroupProcess(2), + 'oss-ciGroup3': kibanaPipeline.ossCiGroupProcess(3), + 'oss-ciGroup4': kibanaPipeline.ossCiGroupProcess(4), + 'oss-ciGroup5': kibanaPipeline.ossCiGroupProcess(5), + 'oss-ciGroup6': kibanaPipeline.ossCiGroupProcess(6), + 'oss-ciGroup7': kibanaPipeline.ossCiGroupProcess(7), + 'oss-ciGroup8': kibanaPipeline.ossCiGroupProcess(8), + 'oss-ciGroup9': kibanaPipeline.ossCiGroupProcess(9), + 'oss-ciGroup10': kibanaPipeline.ossCiGroupProcess(10), + 'oss-ciGroup11': kibanaPipeline.ossCiGroupProcess(11), + 'oss-ciGroup12': kibanaPipeline.ossCiGroupProcess(12), + ]), + 'kibana-xpack-agent': workers.functional('kibana-xpack-tests', { kibanaPipeline.buildXpack() }, [ + 'xpack-ciGroup1': kibanaPipeline.xpackCiGroupProcess(1), + 'xpack-ciGroup2': kibanaPipeline.xpackCiGroupProcess(2), + 'xpack-ciGroup3': kibanaPipeline.xpackCiGroupProcess(3), + 'xpack-ciGroup4': kibanaPipeline.xpackCiGroupProcess(4), + 'xpack-ciGroup5': kibanaPipeline.xpackCiGroupProcess(5), + 'xpack-ciGroup6': kibanaPipeline.xpackCiGroupProcess(6), + 'xpack-ciGroup7': kibanaPipeline.xpackCiGroupProcess(7), + 'xpack-ciGroup8': kibanaPipeline.xpackCiGroupProcess(8), + 'xpack-ciGroup9': kibanaPipeline.xpackCiGroupProcess(9), + 'xpack-ciGroup10': kibanaPipeline.xpackCiGroupProcess(10), + ]), + ]) + } - promoteSnapshot(SNAPSHOT_VERSION, SNAPSHOT_ID) + promoteSnapshot(SNAPSHOT_VERSION, SNAPSHOT_ID) + } } kibanaPipeline.sendMail() diff --git a/.eslintignore b/.eslintignore index 2ed9ecf971ff3..4913192e81c1d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -30,6 +30,7 @@ target /x-pack/legacy/plugins/canvas/canvas_plugin_src/lib/flot-charts /x-pack/legacy/plugins/canvas/shareable_runtime/build /x-pack/legacy/plugins/canvas/storybook +/x-pack/plugins/monitoring/public/lib/jquery_flot /x-pack/legacy/plugins/infra/common/graphql/types.ts /x-pack/legacy/plugins/infra/public/graphql/types.ts /x-pack/legacy/plugins/infra/server/graphql/types.ts diff --git a/.eslintrc.js b/.eslintrc.js index c9b41ec711b7f..8b33ec83347a8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -963,6 +963,12 @@ module.exports = { jquery: true, }, }, + { + files: ['x-pack/plugins/monitoring/public/lib/jquery_flot/**/*.js'], + env: { + jquery: true, + }, + }, /** * TSVB overrides diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a97400ee09c0e..6e616cf78c206 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,26 +3,28 @@ # 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 /src/legacy/server/sample_data/ @elastic/kibana-app -/src/legacy/core_plugins/kibana/public/dashboard/ @elastic/kibana-app -/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 @@ -84,6 +86,7 @@ /x-pack/legacy/plugins/ingest_manager/ @elastic/ingest-management /x-pack/plugins/observability/ @elastic/logs-metrics-ui @elastic/apm-ui @elastic/uptime @elastic/ingest-management /x-pack/legacy/plugins/monitoring/ @elastic/stack-monitoring-ui +/x-pack/plugins/monitoring/ @elastic/stack-monitoring-ui /x-pack/plugins/uptime @elastic/uptime # Machine Learning @@ -203,6 +206,7 @@ /x-pack/plugins/snapshot_restore/ @elastic/es-ui /x-pack/plugins/upgrade_assistant/ @elastic/es-ui /x-pack/plugins/watcher/ @elastic/es-ui +/x-pack/plugins/ingest_pipelines/ @elastic/es-ui # Endpoint /x-pack/plugins/endpoint/ @elastic/endpoint-app-team @elastic/siem diff --git a/.i18nrc.json b/.i18nrc.json index b04c02f6b2265..be3c043b6e52f 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 16aaf55802b17..657e3ec8b8bb1 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.agggrouplabels.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.agggrouplabels.md new file mode 100644 index 0000000000000..6684ba8546f85 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.agggrouplabels.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggGroupLabels](./kibana-plugin-plugins-data-public.agggrouplabels.md) + +## AggGroupLabels variable + +Signature: + +```typescript +AggGroupLabels: { + [AggGroupNames.Buckets]: string; + [AggGroupNames.Metrics]: string; + [AggGroupNames.None]: string; +} +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.agggroupname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.agggroupname.md new file mode 100644 index 0000000000000..d4476398680a8 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.agggroupname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggGroupName](./kibana-plugin-plugins-data-public.agggroupname.md) + +## AggGroupName type + +Signature: + +```typescript +export declare type AggGroupName = $Values; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefieldfilters.addfilter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefieldfilters.addfilter.md deleted file mode 100644 index c9d6772a13b8d..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefieldfilters.addfilter.md +++ /dev/null @@ -1,24 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggTypeFieldFilters](./kibana-plugin-plugins-data-public.aggtypefieldfilters.md) > [addFilter](./kibana-plugin-plugins-data-public.aggtypefieldfilters.addfilter.md) - -## AggTypeFieldFilters.addFilter() method - -Register a new with this registry. This will be used by the . - -Signature: - -```typescript -addFilter(filter: AggTypeFieldFilter): void; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| filter | AggTypeFieldFilter | | - -Returns: - -`void` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefieldfilters.filter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefieldfilters.filter.md deleted file mode 100644 index 038c339bf6774..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefieldfilters.filter.md +++ /dev/null @@ -1,25 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggTypeFieldFilters](./kibana-plugin-plugins-data-public.aggtypefieldfilters.md) > [filter](./kibana-plugin-plugins-data-public.aggtypefieldfilters.filter.md) - -## AggTypeFieldFilters.filter() method - -Returns the filtered by all registered filters. - -Signature: - -```typescript -filter(fields: IndexPatternField[], aggConfig: IAggConfig): IndexPatternField[]; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| fields | IndexPatternField[] | | -| aggConfig | IAggConfig | | - -Returns: - -`IndexPatternField[]` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefieldfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefieldfilters.md deleted file mode 100644 index c0b386efbf9c7..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefieldfilters.md +++ /dev/null @@ -1,21 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggTypeFieldFilters](./kibana-plugin-plugins-data-public.aggtypefieldfilters.md) - -## AggTypeFieldFilters class - -A registry to store which are used to filter down available fields for a specific visualization and . - -Signature: - -```typescript -declare class AggTypeFieldFilters -``` - -## Methods - -| Method | Modifiers | Description | -| --- | --- | --- | -| [addFilter(filter)](./kibana-plugin-plugins-data-public.aggtypefieldfilters.addfilter.md) | | Register a new with this registry. This will be used by the . | -| [filter(fields, aggConfig)](./kibana-plugin-plugins-data-public.aggtypefieldfilters.filter.md) | | Returns the filtered by all registered filters. | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefilters.addfilter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefilters.addfilter.md deleted file mode 100644 index 9df003377c4a1..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefilters.addfilter.md +++ /dev/null @@ -1,24 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggTypeFilters](./kibana-plugin-plugins-data-public.aggtypefilters.md) > [addFilter](./kibana-plugin-plugins-data-public.aggtypefilters.addfilter.md) - -## AggTypeFilters.addFilter() method - -Register a new with this registry. - -Signature: - -```typescript -addFilter(filter: AggTypeFilter): void; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| filter | AggTypeFilter | | - -Returns: - -`void` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefilters.filter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefilters.filter.md deleted file mode 100644 index 81e6e9b95d655..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefilters.filter.md +++ /dev/null @@ -1,27 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggTypeFilters](./kibana-plugin-plugins-data-public.aggtypefilters.md) > [filter](./kibana-plugin-plugins-data-public.aggtypefilters.filter.md) - -## AggTypeFilters.filter() method - -Returns the filtered by all registered filters. - -Signature: - -```typescript -filter(aggTypes: IAggType[], indexPattern: IndexPattern, aggConfig: IAggConfig, aggFilter: string[]): IAggType[]; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| aggTypes | IAggType[] | | -| indexPattern | IndexPattern | | -| aggConfig | IAggConfig | | -| aggFilter | string[] | | - -Returns: - -`IAggType[]` - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefilters.md deleted file mode 100644 index c5e24bc0a78a0..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.aggtypefilters.md +++ /dev/null @@ -1,21 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [AggTypeFilters](./kibana-plugin-plugins-data-public.aggtypefilters.md) - -## AggTypeFilters class - -A registry to store which are used to filter down available aggregations for a specific visualization and . - -Signature: - -```typescript -declare class AggTypeFilters -``` - -## Methods - -| Method | Modifiers | Description | -| --- | --- | --- | -| [addFilter(filter)](./kibana-plugin-plugins-data-public.aggtypefilters.addfilter.md) | | Register a new with this registry. | -| [filter(aggTypes, indexPattern, aggConfig, aggFilter)](./kibana-plugin-plugins-data-public.aggtypefilters.filter.md) | | Returns the filtered by all registered filters. | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.daterangekey.from.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.daterangekey.from.md deleted file mode 100644 index 245269af366bc..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.daterangekey.from.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [DateRangeKey](./kibana-plugin-plugins-data-public.daterangekey.md) > [from](./kibana-plugin-plugins-data-public.daterangekey.from.md) - -## DateRangeKey.from property - -Signature: - -```typescript -from: number; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.daterangekey.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.daterangekey.md deleted file mode 100644 index 540d429dced48..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.daterangekey.md +++ /dev/null @@ -1,19 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [DateRangeKey](./kibana-plugin-plugins-data-public.daterangekey.md) - -## DateRangeKey interface - -Signature: - -```typescript -export interface DateRangeKey -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [from](./kibana-plugin-plugins-data-public.daterangekey.from.md) | number | | -| [to](./kibana-plugin-plugins-data-public.daterangekey.to.md) | number | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.daterangekey.to.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.daterangekey.to.md deleted file mode 100644 index 024a6c2105427..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.daterangekey.to.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [DateRangeKey](./kibana-plugin-plugins-data-public.daterangekey.md) > [to](./kibana-plugin-plugins-data-public.daterangekey.to.md) - -## DateRangeKey.to property - -Signature: - -```typescript -to: number; -``` 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 7fd65e5db35f3..37142cf1794c3 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/development/plugins/data/public/kibana-plugin-plugins-data-public.field.__spec.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.__spec.md deleted file mode 100644 index 43ff9a930b974..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.__spec.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [$$spec](./kibana-plugin-plugins-data-public.field.__spec.md) - -## Field.$$spec property - -Signature: - -```typescript -$$spec: FieldSpec; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field._constructor_.md deleted file mode 100644 index c3b2ac8d30b5a..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field._constructor_.md +++ /dev/null @@ -1,22 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [(constructor)](./kibana-plugin-plugins-data-public.field._constructor_.md) - -## Field.(constructor) - -Constructs a new instance of the `Field` class - -Signature: - -```typescript -constructor(indexPattern: IndexPattern, spec: FieldSpec | Field, shortDotsEnable?: boolean); -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| indexPattern | IndexPattern | | -| spec | FieldSpec | Field | | -| shortDotsEnable | boolean | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.aggregatable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.aggregatable.md deleted file mode 100644 index fcfd7d73c8b0c..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.aggregatable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [aggregatable](./kibana-plugin-plugins-data-public.field.aggregatable.md) - -## Field.aggregatable property - -Signature: - -```typescript -aggregatable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.conflictdescriptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.conflictdescriptions.md deleted file mode 100644 index 21b6917c4aad4..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.conflictdescriptions.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [conflictDescriptions](./kibana-plugin-plugins-data-public.field.conflictdescriptions.md) - -## Field.conflictDescriptions property - -Signature: - -```typescript -conflictDescriptions?: Record; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.count.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.count.md deleted file mode 100644 index 4f51d88a3046e..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.count.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [count](./kibana-plugin-plugins-data-public.field.count.md) - -## Field.count property - -Signature: - -```typescript -count?: number; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.displayname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.displayname.md deleted file mode 100644 index 0846a7595cf90..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.displayname.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [displayName](./kibana-plugin-plugins-data-public.field.displayname.md) - -## Field.displayName property - -Signature: - -```typescript -displayName?: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.estypes.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.estypes.md deleted file mode 100644 index efe1bceb43361..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.estypes.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [esTypes](./kibana-plugin-plugins-data-public.field.estypes.md) - -## Field.esTypes property - -Signature: - -```typescript -esTypes?: string[]; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.filterable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.filterable.md deleted file mode 100644 index fd7be589e87a7..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.filterable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [filterable](./kibana-plugin-plugins-data-public.field.filterable.md) - -## Field.filterable property - -Signature: - -```typescript -filterable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.format.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.format.md deleted file mode 100644 index 431e043d1fecc..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.format.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [format](./kibana-plugin-plugins-data-public.field.format.md) - -## Field.format property - -Signature: - -```typescript -format: any; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.indexpattern.md deleted file mode 100644 index 59420747e0958..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.indexpattern.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [indexPattern](./kibana-plugin-plugins-data-public.field.indexpattern.md) - -## Field.indexPattern property - -Signature: - -```typescript -indexPattern?: IndexPattern; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.lang.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.lang.md deleted file mode 100644 index d51857090356f..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.lang.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [lang](./kibana-plugin-plugins-data-public.field.lang.md) - -## Field.lang property - -Signature: - -```typescript -lang?: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.md deleted file mode 100644 index 86ff2b2c28ae9..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.md +++ /dev/null @@ -1,41 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) - -## Field class - -Signature: - -```typescript -export declare class Field implements IFieldType -``` - -## Constructors - -| Constructor | Modifiers | Description | -| --- | --- | --- | -| [(constructor)(indexPattern, spec, shortDotsEnable)](./kibana-plugin-plugins-data-public.field._constructor_.md) | | Constructs a new instance of the Field class | - -## Properties - -| Property | Modifiers | Type | Description | -| --- | --- | --- | --- | -| [$$spec](./kibana-plugin-plugins-data-public.field.__spec.md) | | FieldSpec | | -| [aggregatable](./kibana-plugin-plugins-data-public.field.aggregatable.md) | | boolean | | -| [conflictDescriptions](./kibana-plugin-plugins-data-public.field.conflictdescriptions.md) | | Record<string, string[]> | | -| [count](./kibana-plugin-plugins-data-public.field.count.md) | | number | | -| [displayName](./kibana-plugin-plugins-data-public.field.displayname.md) | | string | | -| [esTypes](./kibana-plugin-plugins-data-public.field.estypes.md) | | string[] | | -| [filterable](./kibana-plugin-plugins-data-public.field.filterable.md) | | boolean | | -| [format](./kibana-plugin-plugins-data-public.field.format.md) | | any | | -| [indexPattern](./kibana-plugin-plugins-data-public.field.indexpattern.md) | | IndexPattern | | -| [lang](./kibana-plugin-plugins-data-public.field.lang.md) | | string | | -| [name](./kibana-plugin-plugins-data-public.field.name.md) | | string | | -| [script](./kibana-plugin-plugins-data-public.field.script.md) | | string | | -| [scripted](./kibana-plugin-plugins-data-public.field.scripted.md) | | boolean | | -| [searchable](./kibana-plugin-plugins-data-public.field.searchable.md) | | boolean | | -| [sortable](./kibana-plugin-plugins-data-public.field.sortable.md) | | boolean | | -| [subType](./kibana-plugin-plugins-data-public.field.subtype.md) | | IFieldSubType | | -| [type](./kibana-plugin-plugins-data-public.field.type.md) | | string | | -| [visualizable](./kibana-plugin-plugins-data-public.field.visualizable.md) | | boolean | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.name.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.name.md deleted file mode 100644 index d2a9b9b86aefc..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.name.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [name](./kibana-plugin-plugins-data-public.field.name.md) - -## Field.name property - -Signature: - -```typescript -name: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.script.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.script.md deleted file mode 100644 index 676ff9bdfc35a..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.script.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [script](./kibana-plugin-plugins-data-public.field.script.md) - -## Field.script property - -Signature: - -```typescript -script?: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.scripted.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.scripted.md deleted file mode 100644 index 1f6c8105e3f61..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.scripted.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [scripted](./kibana-plugin-plugins-data-public.field.scripted.md) - -## Field.scripted property - -Signature: - -```typescript -scripted?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.searchable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.searchable.md deleted file mode 100644 index 186d344f50378..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.searchable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [searchable](./kibana-plugin-plugins-data-public.field.searchable.md) - -## Field.searchable property - -Signature: - -```typescript -searchable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.sortable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.sortable.md deleted file mode 100644 index 0cd4b14d0e1e5..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.sortable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [sortable](./kibana-plugin-plugins-data-public.field.sortable.md) - -## Field.sortable property - -Signature: - -```typescript -sortable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.subtype.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.subtype.md deleted file mode 100644 index bef3b2131fa47..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.subtype.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [subType](./kibana-plugin-plugins-data-public.field.subtype.md) - -## Field.subType property - -Signature: - -```typescript -subType?: IFieldSubType; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.type.md deleted file mode 100644 index 490615edcf097..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.type.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [type](./kibana-plugin-plugins-data-public.field.type.md) - -## Field.type property - -Signature: - -```typescript -type: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.visualizable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.visualizable.md deleted file mode 100644 index f32a5c456dc5d..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.field.visualizable.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Field](./kibana-plugin-plugins-data-public.field.md) > [visualizable](./kibana-plugin-plugins-data-public.field.visualizable.md) - -## Field.visualizable property - -Signature: - -```typescript -visualizable?: boolean; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getindexpatternfieldlistcreator.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getindexpatternfieldlistcreator.md new file mode 100644 index 0000000000000..60302286cbd72 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.getindexpatternfieldlistcreator.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [getIndexPatternFieldListCreator](./kibana-plugin-plugins-data-public.getindexpatternfieldlistcreator.md) + +## getIndexPatternFieldListCreator variable + +Signature: + +```typescript +getIndexPatternFieldListCreator: ({ fieldFormats, toastNotifications, }: FieldListDependencies) => CreateIndexPatternFieldList +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iagggroupnames.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iagggroupnames.md deleted file mode 100644 index 07310a4219359..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iagggroupnames.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IAggGroupNames](./kibana-plugin-plugins-data-public.iagggroupnames.md) - -## IAggGroupNames type - -Signature: - -```typescript -export declare type IAggGroupNames = $Values; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.add.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.add.md new file mode 100644 index 0000000000000..0f3469ae9c550 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.add.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPatternFieldList](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.md) > [add](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.add.md) + +## IIndexPatternFieldList.add() method + +Signature: + +```typescript +add(field: FieldSpec): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| field | FieldSpec | | + +Returns: + +`void` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbyname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbyname.md new file mode 100644 index 0000000000000..14b5aa7137dc2 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbyname.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPatternFieldList](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.md) > [getByName](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbyname.md) + +## IIndexPatternFieldList.getByName() method + +Signature: + +```typescript +getByName(name: Field['name']): Field | undefined; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| name | Field['name'] | | + +Returns: + +`Field | undefined` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbytype.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbytype.md new file mode 100644 index 0000000000000..3c65b78e5291d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbytype.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPatternFieldList](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.md) > [getByType](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbytype.md) + +## IIndexPatternFieldList.getByType() method + +Signature: + +```typescript +getByType(type: Field['type']): Field[]; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | Field['type'] | | + +Returns: + +`Field[]` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.md new file mode 100644 index 0000000000000..47d7c7491aa86 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPatternFieldList](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.md) + +## IIndexPatternFieldList interface + +Signature: + +```typescript +export interface IIndexPatternFieldList extends Array +``` + +## Methods + +| Method | Description | +| --- | --- | +| [add(field)](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.add.md) | | +| [getByName(name)](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbyname.md) | | +| [getByType(type)](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.getbytype.md) | | +| [remove(field)](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.remove.md) | | +| [update(field)](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.update.md) | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.remove.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.remove.md new file mode 100644 index 0000000000000..3b6bbb0691930 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.remove.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPatternFieldList](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.md) > [remove](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.remove.md) + +## IIndexPatternFieldList.remove() method + +Signature: + +```typescript +remove(field: IFieldType): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| field | IFieldType | | + +Returns: + +`void` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.update.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.update.md new file mode 100644 index 0000000000000..121ffb65f26a5 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iindexpatternfieldlist.update.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IIndexPatternFieldList](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.md) > [update](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.update.md) + +## IIndexPatternFieldList.update() method + +Signature: + +```typescript +update(field: FieldSpec): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| field | FieldSpec | | + +Returns: + +`void` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.fields.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.fields.md index fcd682340eb53..9a93148e4a466 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.fields.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.fields.md @@ -7,5 +7,5 @@ Signature: ```typescript -fields: IFieldList; +fields: IIndexPatternFieldList; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md index 35075e19dcaf6..21a155ba977c9 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpattern.md @@ -21,7 +21,7 @@ export declare class IndexPattern implements IIndexPattern | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [fieldFormatMap](./kibana-plugin-plugins-data-public.indexpattern.fieldformatmap.md) | | any | | -| [fields](./kibana-plugin-plugins-data-public.indexpattern.fields.md) | | IFieldList | | +| [fields](./kibana-plugin-plugins-data-public.indexpattern.fields.md) | | IIndexPatternFieldList | | | [fieldsFetcher](./kibana-plugin-plugins-data-public.indexpattern.fieldsfetcher.md) | | any | | | [flattenHit](./kibana-plugin-plugins-data-public.indexpattern.flattenhit.md) | | any | | | [formatField](./kibana-plugin-plugins-data-public.indexpattern.formatfield.md) | | any | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.__spec.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.__spec.md new file mode 100644 index 0000000000000..f52a3324af36f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.__spec.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [$$spec](./kibana-plugin-plugins-data-public.indexpatternfield.__spec.md) + +## IndexPatternField.$$spec property + +Signature: + +```typescript +$$spec: FieldSpec; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md new file mode 100644 index 0000000000000..8ee9acc684fb1 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [(constructor)](./kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md) + +## IndexPatternField.(constructor) + +Constructs a new instance of the `Field` class + +Signature: + +```typescript +constructor(indexPattern: IndexPattern, spec: FieldSpec | Field, shortDotsEnable: boolean, { fieldFormats, toastNotifications }: FieldDependencies); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| indexPattern | IndexPattern | | +| spec | FieldSpec | Field | | +| shortDotsEnable | boolean | | +| { fieldFormats, toastNotifications } | FieldDependencies | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md new file mode 100644 index 0000000000000..267c8f786b5dd --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [aggregatable](./kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md) + +## IndexPatternField.aggregatable property + +Signature: + +```typescript +aggregatable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md new file mode 100644 index 0000000000000..ca2552aeb1b42 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [conflictDescriptions](./kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md) + +## IndexPatternField.conflictDescriptions property + +Signature: + +```typescript +conflictDescriptions?: Record; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.count.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.count.md new file mode 100644 index 0000000000000..8e848276f21c4 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.count.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [count](./kibana-plugin-plugins-data-public.indexpatternfield.count.md) + +## IndexPatternField.count property + +Signature: + +```typescript +count?: number; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.displayname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.displayname.md new file mode 100644 index 0000000000000..ed9630f92fc97 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.displayname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [displayName](./kibana-plugin-plugins-data-public.indexpatternfield.displayname.md) + +## IndexPatternField.displayName property + +Signature: + +```typescript +displayName?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.estypes.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.estypes.md new file mode 100644 index 0000000000000..dec74df099d43 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.estypes.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [esTypes](./kibana-plugin-plugins-data-public.indexpatternfield.estypes.md) + +## IndexPatternField.esTypes property + +Signature: + +```typescript +esTypes?: string[]; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.filterable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.filterable.md new file mode 100644 index 0000000000000..4290c4a2f86b3 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.filterable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [filterable](./kibana-plugin-plugins-data-public.indexpatternfield.filterable.md) + +## IndexPatternField.filterable property + +Signature: + +```typescript +filterable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.format.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.format.md new file mode 100644 index 0000000000000..d5df8ed628cb0 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.format.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [format](./kibana-plugin-plugins-data-public.indexpatternfield.format.md) + +## IndexPatternField.format property + +Signature: + +```typescript +format: any; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md new file mode 100644 index 0000000000000..d1a1ee0905c6e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [indexPattern](./kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md) + +## IndexPatternField.indexPattern property + +Signature: + +```typescript +indexPattern?: IndexPattern; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.lang.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.lang.md new file mode 100644 index 0000000000000..f731be8f613cf --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.lang.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [lang](./kibana-plugin-plugins-data-public.indexpatternfield.lang.md) + +## IndexPatternField.lang property + +Signature: + +```typescript +lang?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md new file mode 100644 index 0000000000000..a62cee7b654fe --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.md @@ -0,0 +1,41 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) + +## IndexPatternField class + +Signature: + +```typescript +export declare class Field implements IFieldType +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(indexPattern, spec, shortDotsEnable, { fieldFormats, toastNotifications })](./kibana-plugin-plugins-data-public.indexpatternfield._constructor_.md) | | Constructs a new instance of the Field class | + +## Properties + +| Property | Modifiers | Type | Description | +| --- | --- | --- | --- | +| [$$spec](./kibana-plugin-plugins-data-public.indexpatternfield.__spec.md) | | FieldSpec | | +| [aggregatable](./kibana-plugin-plugins-data-public.indexpatternfield.aggregatable.md) | | boolean | | +| [conflictDescriptions](./kibana-plugin-plugins-data-public.indexpatternfield.conflictdescriptions.md) | | Record<string, string[]> | | +| [count](./kibana-plugin-plugins-data-public.indexpatternfield.count.md) | | number | | +| [displayName](./kibana-plugin-plugins-data-public.indexpatternfield.displayname.md) | | string | | +| [esTypes](./kibana-plugin-plugins-data-public.indexpatternfield.estypes.md) | | string[] | | +| [filterable](./kibana-plugin-plugins-data-public.indexpatternfield.filterable.md) | | boolean | | +| [format](./kibana-plugin-plugins-data-public.indexpatternfield.format.md) | | any | | +| [indexPattern](./kibana-plugin-plugins-data-public.indexpatternfield.indexpattern.md) | | IndexPattern | | +| [lang](./kibana-plugin-plugins-data-public.indexpatternfield.lang.md) | | string | | +| [name](./kibana-plugin-plugins-data-public.indexpatternfield.name.md) | | string | | +| [script](./kibana-plugin-plugins-data-public.indexpatternfield.script.md) | | string | | +| [scripted](./kibana-plugin-plugins-data-public.indexpatternfield.scripted.md) | | boolean | | +| [searchable](./kibana-plugin-plugins-data-public.indexpatternfield.searchable.md) | | boolean | | +| [sortable](./kibana-plugin-plugins-data-public.indexpatternfield.sortable.md) | | boolean | | +| [subType](./kibana-plugin-plugins-data-public.indexpatternfield.subtype.md) | | IFieldSubType | | +| [type](./kibana-plugin-plugins-data-public.indexpatternfield.type.md) | | string | | +| [visualizable](./kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md) | | boolean | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.name.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.name.md new file mode 100644 index 0000000000000..cb24621e73209 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.name.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [name](./kibana-plugin-plugins-data-public.indexpatternfield.name.md) + +## IndexPatternField.name property + +Signature: + +```typescript +name: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.script.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.script.md new file mode 100644 index 0000000000000..132ba25a47637 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.script.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [script](./kibana-plugin-plugins-data-public.indexpatternfield.script.md) + +## IndexPatternField.script property + +Signature: + +```typescript +script?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.scripted.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.scripted.md new file mode 100644 index 0000000000000..1dd6bc865a75d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.scripted.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [scripted](./kibana-plugin-plugins-data-public.indexpatternfield.scripted.md) + +## IndexPatternField.scripted property + +Signature: + +```typescript +scripted?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.searchable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.searchable.md new file mode 100644 index 0000000000000..42f984d851435 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.searchable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [searchable](./kibana-plugin-plugins-data-public.indexpatternfield.searchable.md) + +## IndexPatternField.searchable property + +Signature: + +```typescript +searchable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.sortable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.sortable.md new file mode 100644 index 0000000000000..72d225185140b --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.sortable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [sortable](./kibana-plugin-plugins-data-public.indexpatternfield.sortable.md) + +## IndexPatternField.sortable property + +Signature: + +```typescript +sortable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.subtype.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.subtype.md new file mode 100644 index 0000000000000..2d807f8a5739c --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.subtype.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [subType](./kibana-plugin-plugins-data-public.indexpatternfield.subtype.md) + +## IndexPatternField.subType property + +Signature: + +```typescript +subType?: IFieldSubType; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.type.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.type.md new file mode 100644 index 0000000000000..c8483c9b83c9a --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [type](./kibana-plugin-plugins-data-public.indexpatternfield.type.md) + +## IndexPatternField.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md new file mode 100644 index 0000000000000..dd661ae779c11 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) > [visualizable](./kibana-plugin-plugins-data-public.indexpatternfield.visualizable.md) + +## IndexPatternField.visualizable property + +Signature: + +```typescript +visualizable?: boolean; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist._constructor_.md deleted file mode 100644 index 2207107db8b2b..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist._constructor_.md +++ /dev/null @@ -1,22 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternFieldList](./kibana-plugin-plugins-data-public.indexpatternfieldlist.md) > [(constructor)](./kibana-plugin-plugins-data-public.indexpatternfieldlist._constructor_.md) - -## IndexPatternFieldList.(constructor) - -Constructs a new instance of the `FieldList` class - -Signature: - -```typescript -constructor(indexPattern: IndexPattern, specs?: FieldSpec[], shortDotsEnable?: boolean); -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| indexPattern | IndexPattern | | -| specs | FieldSpec[] | | -| shortDotsEnable | boolean | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.add.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.add.md deleted file mode 100644 index dce2f38bbcf10..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.add.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternFieldList](./kibana-plugin-plugins-data-public.indexpatternfieldlist.md) > [add](./kibana-plugin-plugins-data-public.indexpatternfieldlist.add.md) - -## IndexPatternFieldList.add property - -Signature: - -```typescript -add: (field: Record) => void; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.getbyname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.getbyname.md deleted file mode 100644 index bf6bc51b60301..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.getbyname.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternFieldList](./kibana-plugin-plugins-data-public.indexpatternfieldlist.md) > [getByName](./kibana-plugin-plugins-data-public.indexpatternfieldlist.getbyname.md) - -## IndexPatternFieldList.getByName property - -Signature: - -```typescript -getByName: (name: string) => Field | undefined; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.getbytype.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.getbytype.md deleted file mode 100644 index 86c5ae32940d4..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.getbytype.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternFieldList](./kibana-plugin-plugins-data-public.indexpatternfieldlist.md) > [getByType](./kibana-plugin-plugins-data-public.indexpatternfieldlist.getbytype.md) - -## IndexPatternFieldList.getByType property - -Signature: - -```typescript -getByType: (type: string) => any[]; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.md deleted file mode 100644 index 478b73f5f8581..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.md +++ /dev/null @@ -1,28 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternFieldList](./kibana-plugin-plugins-data-public.indexpatternfieldlist.md) - -## IndexPatternFieldList class - -Signature: - -```typescript -export declare class FieldList extends Array implements IFieldList -``` - -## Constructors - -| Constructor | Modifiers | Description | -| --- | --- | --- | -| [(constructor)(indexPattern, specs, shortDotsEnable)](./kibana-plugin-plugins-data-public.indexpatternfieldlist._constructor_.md) | | Constructs a new instance of the FieldList class | - -## Properties - -| Property | Modifiers | Type | Description | -| --- | --- | --- | --- | -| [add](./kibana-plugin-plugins-data-public.indexpatternfieldlist.add.md) | | (field: Record<string, any>) => void | | -| [getByName](./kibana-plugin-plugins-data-public.indexpatternfieldlist.getbyname.md) | | (name: string) => Field | undefined | | -| [getByType](./kibana-plugin-plugins-data-public.indexpatternfieldlist.getbytype.md) | | (type: string) => any[] | | -| [remove](./kibana-plugin-plugins-data-public.indexpatternfieldlist.remove.md) | | (field: IFieldType) => void | | -| [update](./kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md) | | (field: Record<string, any>) => void | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.remove.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.remove.md deleted file mode 100644 index 1f2e0883d272e..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.remove.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternFieldList](./kibana-plugin-plugins-data-public.indexpatternfieldlist.md) > [remove](./kibana-plugin-plugins-data-public.indexpatternfieldlist.remove.md) - -## IndexPatternFieldList.remove property - -Signature: - -```typescript -remove: (field: IFieldType) => void; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md deleted file mode 100644 index d5156ed41e493..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IndexPatternFieldList](./kibana-plugin-plugins-data-public.indexpatternfieldlist.md) > [update](./kibana-plugin-plugins-data-public.indexpatternfieldlist.update.md) - -## IndexPatternFieldList.update property - -Signature: - -```typescript -update: (field: Record) => void; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iprangekey.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iprangekey.md deleted file mode 100644 index 96903a5df9844..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.iprangekey.md +++ /dev/null @@ -1,18 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IpRangeKey](./kibana-plugin-plugins-data-public.iprangekey.md) - -## IpRangeKey type - -Signature: - -```typescript -export declare type IpRangeKey = { - type: 'mask'; - mask: string; -} | { - type: 'range'; - from: string; - to: string; -}; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index e1df493143b73..8b58957b9044a 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -9,13 +9,10 @@ | Class | Description | | --- | --- | | [AggParamType](./kibana-plugin-plugins-data-public.aggparamtype.md) | | -| [AggTypeFieldFilters](./kibana-plugin-plugins-data-public.aggtypefieldfilters.md) | A registry to store which are used to filter down available fields for a specific visualization and . | -| [AggTypeFilters](./kibana-plugin-plugins-data-public.aggtypefilters.md) | A registry to store which are used to filter down available aggregations for a specific visualization and . | -| [Field](./kibana-plugin-plugins-data-public.field.md) | | | [FieldFormat](./kibana-plugin-plugins-data-public.fieldformat.md) | | | [FilterManager](./kibana-plugin-plugins-data-public.filtermanager.md) | | | [IndexPattern](./kibana-plugin-plugins-data-public.indexpattern.md) | | -| [IndexPatternFieldList](./kibana-plugin-plugins-data-public.indexpatternfieldlist.md) | | +| [IndexPatternField](./kibana-plugin-plugins-data-public.indexpatternfield.md) | | | [IndexPatternSelect](./kibana-plugin-plugins-data-public.indexpatternselect.md) | | | [OptionedParamType](./kibana-plugin-plugins-data-public.optionedparamtype.md) | | | [Plugin](./kibana-plugin-plugins-data-public.plugin.md) | | @@ -53,7 +50,6 @@ | [AggParamOption](./kibana-plugin-plugins-data-public.aggparamoption.md) | | | [DataPublicPluginSetup](./kibana-plugin-plugins-data-public.datapublicpluginsetup.md) | | | [DataPublicPluginStart](./kibana-plugin-plugins-data-public.datapublicpluginstart.md) | | -| [DateRangeKey](./kibana-plugin-plugins-data-public.daterangekey.md) | | | [EsQueryConfig](./kibana-plugin-plugins-data-public.esqueryconfig.md) | | | [FetchOptions](./kibana-plugin-plugins-data-public.fetchoptions.md) | | | [FieldFormatConfig](./kibana-plugin-plugins-data-public.fieldformatconfig.md) | | @@ -64,6 +60,7 @@ | [IFieldSubType](./kibana-plugin-plugins-data-public.ifieldsubtype.md) | | | [IFieldType](./kibana-plugin-plugins-data-public.ifieldtype.md) | | | [IIndexPattern](./kibana-plugin-plugins-data-public.iindexpattern.md) | | +| [IIndexPatternFieldList](./kibana-plugin-plugins-data-public.iindexpatternfieldlist.md) | | | [IKibanaSearchRequest](./kibana-plugin-plugins-data-public.ikibanasearchrequest.md) | | | [IKibanaSearchResponse](./kibana-plugin-plugins-data-public.ikibanasearchresponse.md) | | | [IndexPatternAttributes](./kibana-plugin-plugins-data-public.indexpatternattributes.md) | Use data plugin interface instead | @@ -75,7 +72,6 @@ | [ISearchStrategy](./kibana-plugin-plugins-data-public.isearchstrategy.md) | Search strategy interface contains a search method that takes in a request and returns a promise that resolves to a response. | | [ISyncSearchRequest](./kibana-plugin-plugins-data-public.isyncsearchrequest.md) | | | [KueryNode](./kibana-plugin-plugins-data-public.kuerynode.md) | | -| [OptionedParamEditorProps](./kibana-plugin-plugins-data-public.optionedparameditorprops.md) | | | [OptionedValueProp](./kibana-plugin-plugins-data-public.optionedvalueprop.md) | | | [Query](./kibana-plugin-plugins-data-public.query.md) | | | [QueryState](./kibana-plugin-plugins-data-public.querystate.md) | All query state service state | @@ -95,6 +91,7 @@ | Variable | Description | | --- | --- | +| [AggGroupLabels](./kibana-plugin-plugins-data-public.agggrouplabels.md) | | | [AggGroupNames](./kibana-plugin-plugins-data-public.agggroupnames.md) | | | [baseFormattersPublic](./kibana-plugin-plugins-data-public.baseformatterspublic.md) | | | [castEsToKbnFieldTypeName](./kibana-plugin-plugins-data-public.castestokbnfieldtypename.md) | Get the KbnFieldType name for an esType string | @@ -106,6 +103,7 @@ | [esQuery](./kibana-plugin-plugins-data-public.esquery.md) | | | [fieldFormats](./kibana-plugin-plugins-data-public.fieldformats.md) | | | [FilterBar](./kibana-plugin-plugins-data-public.filterbar.md) | | +| [getIndexPatternFieldListCreator](./kibana-plugin-plugins-data-public.getindexpatternfieldlistcreator.md) | | | [getKbnTypeNames](./kibana-plugin-plugins-data-public.getkbntypenames.md) | Get the esTypes known by all kbnFieldTypes {Array} | | [indexPatterns](./kibana-plugin-plugins-data-public.indexpatterns.md) | | | [QueryStringInput](./kibana-plugin-plugins-data-public.querystringinput.md) | | @@ -119,6 +117,7 @@ | Type Alias | Description | | --- | --- | | [AggConfigOptions](./kibana-plugin-plugins-data-public.aggconfigoptions.md) | | +| [AggGroupName](./kibana-plugin-plugins-data-public.agggroupname.md) | | | [AggParam](./kibana-plugin-plugins-data-public.aggparam.md) | | | [CustomFilter](./kibana-plugin-plugins-data-public.customfilter.md) | | | [EsQuerySortValue](./kibana-plugin-plugins-data-public.esquerysortvalue.md) | | @@ -127,7 +126,6 @@ | [FieldFormatsContentType](./kibana-plugin-plugins-data-public.fieldformatscontenttype.md) | \* | | [FieldFormatsGetConfigFn](./kibana-plugin-plugins-data-public.fieldformatsgetconfigfn.md) | | | [IAggConfig](./kibana-plugin-plugins-data-public.iaggconfig.md) | AggConfig This class represents an aggregation, which is displayed in the left-hand nav of the Visualize app. | -| [IAggGroupNames](./kibana-plugin-plugins-data-public.iagggroupnames.md) | | | [IAggType](./kibana-plugin-plugins-data-public.iaggtype.md) | | | [IFieldFormat](./kibana-plugin-plugins-data-public.ifieldformat.md) | | | [IFieldFormatsRegistry](./kibana-plugin-plugins-data-public.ifieldformatsregistry.md) | | @@ -136,7 +134,6 @@ | [IndexPatternAggRestrictions](./kibana-plugin-plugins-data-public.indexpatternaggrestrictions.md) | | | [IndexPatternsContract](./kibana-plugin-plugins-data-public.indexpatternscontract.md) | | | [InputTimeRange](./kibana-plugin-plugins-data-public.inputtimerange.md) | | -| [IpRangeKey](./kibana-plugin-plugins-data-public.iprangekey.md) | | | [ISearch](./kibana-plugin-plugins-data-public.isearch.md) | | | [ISearchGeneric](./kibana-plugin-plugins-data-public.isearchgeneric.md) | | | [ISearchSource](./kibana-plugin-plugins-data-public.isearchsource.md) | \* | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.optionedparameditorprops.aggparam.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.optionedparameditorprops.aggparam.md deleted file mode 100644 index 68e4371acc2f3..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.optionedparameditorprops.aggparam.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [OptionedParamEditorProps](./kibana-plugin-plugins-data-public.optionedparameditorprops.md) > [aggParam](./kibana-plugin-plugins-data-public.optionedparameditorprops.aggparam.md) - -## OptionedParamEditorProps.aggParam property - -Signature: - -```typescript -aggParam: { - options: T[]; - }; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.optionedparameditorprops.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.optionedparameditorprops.md deleted file mode 100644 index 00a440a0a775a..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.optionedparameditorprops.md +++ /dev/null @@ -1,18 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [OptionedParamEditorProps](./kibana-plugin-plugins-data-public.optionedparameditorprops.md) - -## OptionedParamEditorProps interface - -Signature: - -```typescript -export interface OptionedParamEditorProps -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [aggParam](./kibana-plugin-plugins-data-public.optionedparameditorprops.aggparam.md) | {
options: T[];
} | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md index d0d4cc491e142..58690300b3bd6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md @@ -7,5 +7,5 @@ Signature: ```typescript -QueryStringInput: React.FC> +QueryStringInput: React.FC> ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md index 9a22339fd0530..67c4eac67a9e6 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.search.md @@ -9,12 +9,7 @@ ```typescript search: { aggs: { - AggConfigs: typeof AggConfigs; - aggGroupNamesMap: () => Record<"metrics" | "buckets", string>; - aggTypeFilters: import("./search/aggs/filter/agg_type_filters").AggTypeFilters; CidrMask: typeof CidrMask; - convertDateRangeToString: typeof convertDateRangeToString; - convertIPRangeToString: (range: import("./search").IpRangeKey, format: (val: any) => string) => string; dateHistogramInterval: typeof dateHistogramInterval; intervalOptions: ({ display: string; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index a0b879673e553..0d5e0e42af27f 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "indexPatterns" | "refreshInterval" | "screenTitle" | "dataTestSubj" | "customSubmitButton" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "onRefresh" | "timeHistory" | "onFiltersUpdated" | "onRefreshChange">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "indexPatterns" | "refreshInterval" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "onRefresh" | "timeHistory" | "onFiltersUpdated" | "onRefreshChange">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/docs/discover/images/autorefresh-interval.png b/docs/discover/images/autorefresh-interval.png new file mode 100644 index 0000000000000..a9f72a1cb73cd Binary files /dev/null and b/docs/discover/images/autorefresh-interval.png differ diff --git a/docs/discover/search.asciidoc b/docs/discover/search.asciidoc index 21ae4560fba94..9fe35f0302760 100644 --- a/docs/discover/search.asciidoc +++ b/docs/discover/search.asciidoc @@ -1,25 +1,53 @@ [[search]] == Searching your data -You can search the indices that match the current <> by entering -your search criteria in the Query bar. By default you can use Kibana's <> -which features autocomplete and a simple, easy to use syntax. Kibana's legacy query -language (based on Lucene https://lucene.apache.org/core/2_9_4/queryparsersyntax.html[query syntax]) -is still available for the time being under the options menu in the Query Bar. When this -legacy query language is selected, the full JSON-based {ref}/query-dsl.html[Elasticsearch Query DSL] -can also be used. - -When you submit a search request, the histogram, Documents table, and Fields -list are updated to reflect the search results. The total number of hits -(matching documents) is shown in the toolbar. The Documents table shows the -first five hundred hits. By default, the hits are listed in reverse -chronological order, with the newest documents shown first. You can reverse -the sort order by clicking the Time column header. You can also sort the table -by the values in any indexed field. For more information, see <>. - -To search your data, enter your search criteria in the Query bar and -press *Enter* or click *Search* image:images/search-button.jpg[] to submit -the request to Elasticsearch. +Many Kibana apps embed a query bar for real-time search, including +*Discover*, *Visualize*, and *Dashboard*. + +[float] +=== Search your data + +To search the indices that match the current <>, +enter your search criteria in the query bar. By default, you'll use +{kib}'s <> (KQL), which +features autocomplete and a simple, easy-to-use syntax. If you prefer to use +{kib}'s legacy query +language, based on the +Lucene https://lucene.apache.org/core/2_9_4/queryparsersyntax.html[query syntax], +you can switch to it from the KQL popup in the query bar. When you enable the +legacy query language, you can use the full +JSON-based {ref}/query-dsl.html[Elasticsearch Query DSL]. + + +[float] +[[autorefresh]] +=== Refresh search results +As more documents are added to the indices you're searching, the search results +shown in *Discover*, and used to display visualizations, get stale. Using the +time filter, you can +configure a refresh interval to periodically resubmit your searches to +retrieve the latest results. + +[role="screenshot"] +image::images/autorefresh-interval.png[] + +You can also manually refresh the search results by +clicking the *Refresh* button. + +[float] +=== Searching large amounts of data + +Sometimes you want to search through large amounts of data no matter how long +the search takes. While this might not happen often, there are times +that long-running queries are required. Consider a threat hunting scenario +where you need to search through years of data. + +If you run a query, and the run time gets close to the +timeout, you're presented the option to ignore the timeout. This enables you to +run queries with large amounts of data to completion. + +By default, a query times out after 30 seconds. +The timeout is in place to avoid unintentional load on the cluster. + include::kuery.asciidoc[] @@ -160,31 +188,3 @@ To completely delete a query: image::discover/images/saved-query-management-component-delete-query-button.png["Example of the saved query management popover when a query is hovered over and we are about to delete a query",width="80%"] You can import, export, and delete saved queries from <>. - -[[select-pattern]] -=== Change the indices you're searching -When you submit a search request, the indices that match the currently-selected -index pattern are searched. -To change the indices you are searching, click the index pattern and select a -different <>. - -[[autorefresh]] -=== Refresh the search results -As more documents are added to the indices you're searching, the search results -shown in Discover and used to display visualizations get stale. You can -configure a refresh interval to periodically resubmit your searches to -retrieve the latest results. - -. Click image:images/time-filter-calendar.png[]. - -. In the *Refresh every* field, enter the refresh rate, then select the interval - from the dropdown. - -. Click *Start*. -+ -image::images/autorefresh-intervals.png[] - -To disable auto refresh, click *Stop*. - -If auto refresh is not enabled, click *Refresh* to manually refresh the search -results. diff --git a/docs/images/autorefresh-intervals.png b/docs/images/autorefresh-intervals.png index a79ae2f1f6c46..49be46fefd4aa 100644 Binary files a/docs/images/autorefresh-intervals.png and b/docs/images/autorefresh-intervals.png differ diff --git a/docs/logs/images/alert-actions-menu.png b/docs/logs/images/alert-actions-menu.png new file mode 100644 index 0000000000000..3f96a700a0ac1 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 0000000000000..30c8857758a8b 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 b12dc096bff45..0d225e5e89c17 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 0000000000000..f08a09187a0c8 --- /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 a5503969a3ec1..85d580de9475f 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 d4dbe9407b7a9..547b4fdedcec6 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 fd53c3aeb3605..8844fcd03ae9a 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 436f169b82ca3..c43b96a8668e0 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 7b32372a1f59a..2a9d4df1ff43c 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 7e597362b1cfc..8ccff21a26f74 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 4fe466bcb4580..6d92e74f17cb2 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 36578c909f513..24e38e73bca9b 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 6645f49029a51..f180f2c3ecc97 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 9a45fb9ab1d0c..7c50dbf542d0d 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 16d68a7759f77..8f6905d643139 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 bb0a15b29a087..bda5f00f762cd 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 ad5f53ad879f8..33f167b13b310 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 41fe8d337c03b..cc662af08b8f1 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 abdcc7d1ba524..673b4f6263e18 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/docs/user/discover.asciidoc b/docs/user/discover.asciidoc index 2547b38a22616..7bac80237a26e 100644 --- a/docs/user/discover.asciidoc +++ b/docs/user/discover.asciidoc @@ -21,6 +21,7 @@ image::images/Discover-Start.png[Discover] [float] +[[select-pattern]] === Set up your index pattern The first thing to do in *Discover* is to select an <>, which diff --git a/docs/user/reporting/development/pdf-integration.asciidoc b/docs/user/reporting/development/pdf-integration.asciidoc index af5ba5be1636e..e9f32de41baab 100644 --- a/docs/user/reporting/development/pdf-integration.asciidoc +++ b/docs/user/reporting/development/pdf-integration.asciidoc @@ -63,3 +63,5 @@ If there are multiple visualizations, the `data-shared-items-count` attribute sh many Visualizations to look for. Reporting will look at every element with the `data-shared-item` attribute and use the corresponding `data-render-complete` attribute and `renderComplete` events to listen for rendering to complete. When rendering is complete for a visualization the `data-render-complete` attribute should be set to "true" and it should dispatch a custom DOM `renderComplete` event. + +If the reporting job uses multiple URLs, before looking for any of the `data-shared-item` or `data-shared-items-count` attributes, it waits for a `data-shared-page` attribute that specifies which page is being loaded. diff --git a/examples/alerting_example/public/application.tsx b/examples/alerting_example/public/application.tsx index 6ff5a7d0880b8..23e9d19441002 100644 --- a/examples/alerting_example/public/application.tsx +++ b/examples/alerting_example/public/application.tsx @@ -27,6 +27,7 @@ import { IUiSettingsClient, DocLinksStart, ToastsSetup, + ApplicationStart, } from '../../../src/core/public'; import { DataPublicPluginStart } from '../../../src/plugins/data/public'; import { ChartsPluginStart } from '../../../src/plugins/charts/public'; @@ -48,6 +49,7 @@ export interface AlertingExampleComponentParams { uiSettings: IUiSettingsClient; docLinks: DocLinksStart; toastNotifications: ToastsSetup; + capabilities: ApplicationStart['capabilities']; } const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { @@ -102,6 +104,7 @@ export const renderApp = ( http={http} uiSettings={uiSettings} docLinks={docLinks} + capabilities={application.capabilities} {...deps} />, element diff --git a/examples/alerting_example/public/components/create_alert.tsx b/examples/alerting_example/public/components/create_alert.tsx index 0541e0b18a2e1..a8e1f06cb3914 100644 --- a/examples/alerting_example/public/components/create_alert.tsx +++ b/examples/alerting_example/public/components/create_alert.tsx @@ -36,6 +36,7 @@ export const CreateAlert = ({ docLinks, data, toastNotifications, + capabilities, }: AlertingExampleComponentParams) => { const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); @@ -60,6 +61,7 @@ export const CreateAlert = ({ docLinks, charts, dataFieldsFormats: data.fieldFormats, + capabilities, }} > = () => 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 c47746d4b3fd6..3a9f673261e33 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 462f5c3bf88ba..f08b8bb29bdd3 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 f1895905a45e1..de86b51aee3a8 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 0ad304fdf2f69..1f0658bd2a138 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "@babel/core": "^7.9.0", "@babel/register": "^7.9.0", "@elastic/apm-rum": "^5.1.1", - "@elastic/charts": "18.4.2", + "@elastic/charts": "19.1.2", "@elastic/datemath": "5.0.3", "@elastic/ems-client": "7.8.0", "@elastic/eui": "22.3.0", @@ -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 b3e5a8c518682..b7c9a63897bf9 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 ad743933e1171..248b0b7cf4c97 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 cc3fa8c2720de..95e826e7620aa 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 a60e2b0449d95..a2248f1ae655e 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -9,11 +9,12 @@ "kbn:watch": "node scripts/build --watch" }, "dependencies": { - "@elastic/charts": "18.4.2", + "@elastic/charts": "19.1.2", "@elastic/eui": "22.3.0", "@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 bf63c57765859..ca913d0f16417 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,19 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ new webpack.DefinePlugin({ 'process.env.NODE_ENV': dev ? '"development"' : '"production"', }), + ...(dev + ? [] + : [ + 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 c0ddcaf4f23c8..61b2485ecf44b 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -398,6 +398,8 @@ '@types/good-squeeze', 'inert', '@types/inert', + 'accept', + '@types/accept', ], }, { diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index 32a23d74dbda4..97dec3eead303 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -259,13 +259,15 @@ export class ClusterManager { const ignorePaths = [ /[\\\/](\..*|node_modules|bower_components|target|public|__[a-z0-9_]+__|coverage)([\\\/]|$)/, - /\.test\.(js|ts)$/, + /\.test\.(js|tsx?)$/, + /\.md$/, + /debug\.log$/, ...pluginInternalDirsIgnore, fromRoot('src/legacy/server/sass/__tmp__'), fromRoot('x-pack/legacy/plugins/reporting/.chromium'), fromRoot('x-pack/plugins/siem/cypress'), - fromRoot('x-pack/legacy/plugins/apm/e2e'), - fromRoot('x-pack/legacy/plugins/apm/scripts'), + fromRoot('x-pack/plugins/apm/e2e'), + fromRoot('x-pack/plugins/apm/scripts'), fromRoot('x-pack/legacy/plugins/canvas/canvas_plugin_src'), // prevents server from restarting twice for Canvas plugin changes, 'plugins/java_languageserver', ]; 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 376b320b64ea9..c085fb028cd5a 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 e29837aecb125..04ff844ffc150 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 bafa1932e5e92..0dd77072e9eaf 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 2f26bc1409104..915c58b28ad6d 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 9092177da5ad4..fa04b56f83ba1 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 0000000000000..4f8fec10a97e1 --- /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 c538227e8f098..2ee71a5bde7dc 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 e12a0f2cf2fcd..aad7e6dcf270a 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 4c135c5769067..ea7c5c9308fe2 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 b609b2ce1d741..444430175d4f2 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/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index da8846f6dddbb..a7d78b56ff3fd 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -18,6 +18,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import { Client } from 'elasticsearch'; import { IClusterClient, ICustomClusterClient } from './cluster_client'; import { IScopedClusterClient } from './scoped_cluster_client'; import { ElasticsearchConfig } from './elasticsearch_config'; @@ -130,6 +131,55 @@ const createMock = () => { return mocked; }; +const createElasticsearchClientMock = () => { + const mocked: jest.Mocked = { + cat: {} as any, + cluster: {} as any, + indices: {} as any, + ingest: {} as any, + nodes: {} as any, + snapshot: {} as any, + tasks: {} as any, + bulk: jest.fn(), + clearScroll: jest.fn(), + count: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + deleteByQuery: jest.fn(), + deleteScript: jest.fn(), + deleteTemplate: jest.fn(), + exists: jest.fn(), + explain: jest.fn(), + fieldStats: jest.fn(), + get: jest.fn(), + getScript: jest.fn(), + getSource: jest.fn(), + getTemplate: jest.fn(), + index: jest.fn(), + info: jest.fn(), + mget: jest.fn(), + msearch: jest.fn(), + msearchTemplate: jest.fn(), + mtermvectors: jest.fn(), + ping: jest.fn(), + putScript: jest.fn(), + putTemplate: jest.fn(), + reindex: jest.fn(), + reindexRethrottle: jest.fn(), + renderSearchTemplate: jest.fn(), + scroll: jest.fn(), + search: jest.fn(), + searchShards: jest.fn(), + searchTemplate: jest.fn(), + suggest: jest.fn(), + termvectors: jest.fn(), + update: jest.fn(), + updateByQuery: jest.fn(), + close: jest.fn(), + }; + return mocked; +}; + export const elasticsearchServiceMock = { create: createMock, createInternalSetup: createInternalSetupContractMock, @@ -138,4 +188,5 @@ export const elasticsearchServiceMock = { createClusterClient: createClusterClientMock, createCustomClusterClient: createCustomClusterClientMock, createScopedClusterClient: createScopedClusterClientMock, + createElasticsearchClient: createElasticsearchClientMock, }; diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 27db79bb94d25..4fb433b5c77ba 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -1068,6 +1068,14 @@ describe('setup contract', () => { await create(); expect(create()).rejects.toThrowError('A cookieSessionStorageFactory was already created'); }); + + it('does not throw if called after stop', async () => { + const { createCookieSessionStorageFactory } = await server.setup(config); + await server.stop(); + expect(() => { + createCookieSessionStorageFactory(cookieOptions); + }).not.toThrow(); + }); }); describe('#isTlsEnabled', () => { @@ -1113,4 +1121,54 @@ describe('setup contract', () => { expect(getServerInfo().protocol).toEqual('https'); }); }); + + describe('#registerStaticDir', () => { + it('does not throw if called after stop', async () => { + const { registerStaticDir } = await server.setup(config); + await server.stop(); + expect(() => { + registerStaticDir('/path1/{path*}', '/path/to/resource'); + }).not.toThrow(); + }); + }); + + describe('#registerOnPreAuth', () => { + test('does not throw if called after stop', async () => { + const { registerOnPreAuth } = await server.setup(config); + await server.stop(); + expect(() => { + registerOnPreAuth((req, res) => res.unauthorized()); + }).not.toThrow(); + }); + }); + + describe('#registerOnPostAuth', () => { + test('does not throw if called after stop', async () => { + const { registerOnPostAuth } = await server.setup(config); + await server.stop(); + expect(() => { + registerOnPostAuth((req, res) => res.unauthorized()); + }).not.toThrow(); + }); + }); + + describe('#registerOnPreResponse', () => { + test('does not throw if called after stop', async () => { + const { registerOnPreResponse } = await server.setup(config); + await server.stop(); + expect(() => { + registerOnPreResponse((req, res, t) => t.next()); + }).not.toThrow(); + }); + }); + + describe('#registerAuth', () => { + test('does not throw if called after stop', async () => { + const { registerAuth } = await server.setup(config); + await server.stop(); + expect(() => { + registerAuth((req, res) => res.unauthorized()); + }).not.toThrow(); + }); + }); }); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 77d3d99fb48cb..92ac5220735a1 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -74,6 +74,7 @@ export class HttpServer { private registeredRouters = new Set(); private authRegistered = false; private cookieSessionStorageCreated = false; + private stopped = false; private readonly log: Logger; private readonly authState: AuthStateStorage; @@ -144,6 +145,10 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Http server is not setup up yet'); } + if (this.stopped) { + this.log.warn(`start called after stop`); + return; + } this.log.debug('starting http server'); for (const router of this.registeredRouters) { @@ -189,13 +194,13 @@ export class HttpServer { } public async stop() { + this.stopped = true; if (this.server === undefined) { return; } this.log.debug('stopping http server'); await this.server.stop(); - this.server = undefined; } private getAuthOption( @@ -234,6 +239,9 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } + if (this.stopped) { + this.log.warn(`setupConditionalCompression called after stop`); + } const { enabled, referrerWhitelist: list } = config.compression; if (!enabled) { @@ -261,6 +269,9 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } + if (this.stopped) { + this.log.warn(`registerOnPostAuth called after stop`); + } this.server.ext('onPostAuth', adoptToHapiOnPostAuthFormat(fn, this.log)); } @@ -269,6 +280,9 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } + if (this.stopped) { + this.log.warn(`registerOnPreAuth called after stop`); + } this.server.ext('onRequest', adoptToHapiOnPreAuthFormat(fn, this.log)); } @@ -277,6 +291,9 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } + if (this.stopped) { + this.log.warn(`registerOnPreResponse called after stop`); + } this.server.ext('onPreResponse', adoptToHapiOnPreResponseFormat(fn, this.log)); } @@ -288,6 +305,9 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } + if (this.stopped) { + this.log.warn(`createCookieSessionStorageFactory called after stop`); + } if (this.cookieSessionStorageCreated) { throw new Error('A cookieSessionStorageFactory was already created'); } @@ -305,6 +325,9 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Server is not created yet'); } + if (this.stopped) { + this.log.warn(`registerAuth called after stop`); + } if (this.authRegistered) { throw new Error('Auth interceptor was already registered'); } @@ -348,6 +371,9 @@ export class HttpServer { if (this.server === undefined) { throw new Error('Http server is not setup up yet'); } + if (this.stopped) { + this.log.warn(`registerStaticDir called after stop`); + } this.server.route({ path, diff --git a/src/core/server/http/integration_tests/core_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts index 6fa3357168027..b3adda8dd22d1 100644 --- a/src/core/server/http/integration_tests/core_service.test.mocks.ts +++ b/src/core/server/http/integration_tests/core_service.test.mocks.ts @@ -24,3 +24,14 @@ jest.doMock('../../elasticsearch/scoped_cluster_client', () => ({ return elasticsearchServiceMock.createScopedClusterClient(); }), })); + +jest.doMock('elasticsearch', () => { + const realES = jest.requireActual('elasticsearch'); + return { + ...realES, + // eslint-disable-next-line object-shorthand + Client: function() { + return elasticsearchServiceMock.createElasticsearchClient(); + }, + }; +}); diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 7b1630a7de0be..c7925f5b6d821 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -43,7 +43,7 @@ describe('http service', () => { describe('auth', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot({ migrations: { skip: true } }); + root = kbnTestServer.createRoot({ plugins: { initialize: false } }); }, 30000); afterEach(async () => { @@ -192,7 +192,7 @@ describe('http service', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot({ migrations: { skip: true } }); + root = kbnTestServer.createRoot({ plugins: { initialize: false } }); }, 30000); afterEach(async () => { @@ -326,7 +326,7 @@ describe('http service', () => { describe('#basePath()', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot({ migrations: { skip: true } }); + root = kbnTestServer.createRoot({ plugins: { initialize: false } }); }, 30000); afterEach(async () => await root.shutdown()); @@ -355,7 +355,7 @@ describe('http service', () => { describe('elasticsearch', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot({ migrations: { skip: true } }); + root = kbnTestServer.createRoot({ plugins: { initialize: false } }); }, 30000); afterEach(async () => { diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 66296736b3ad0..8630221b3e94f 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -35,9 +35,9 @@ export const IGNORE_FILE_GLOBS = [ '**/Gruntfile.js', 'tasks/config/**/*', '**/{Dockerfile,docker-compose.yml}', - 'x-pack/legacy/plugins/apm/**/*', 'x-pack/legacy/plugins/canvas/tasks/**/*', 'x-pack/legacy/plugins/canvas/canvas_plugin_src/**/*', + 'x-pack/plugins/monitoring/public/lib/jquery_flot/**/*', '**/.*', '**/{webpackShims,__mocks__}/**/*', 'x-pack/docs/**/*', @@ -58,6 +58,11 @@ export const IGNORE_FILE_GLOBS = [ // filename required by api-extractor 'api-documenter.json', + + // TODO fix file names in APM to remove these + 'x-pack/plugins/apm/public/**/*', + 'x-pack/plugins/apm/scripts/**/*', + 'x-pack/plugins/apm/e2e/**/*', ]; /** @@ -160,12 +165,11 @@ export const TEMPORARILY_IGNORED_PATHS = [ 'webpackShims/ui-bootstrap.js', 'x-pack/legacy/plugins/index_management/public/lib/editSettings.js', 'x-pack/legacy/plugins/license_management/public/store/reducers/licenseManagement.js', - 'x-pack/legacy/plugins/monitoring/public/components/sparkline/__mocks__/plugins/xpack_main/jquery_flot.js', - 'x-pack/legacy/plugins/monitoring/public/icons/alert-blue.svg', - 'x-pack/legacy/plugins/monitoring/public/icons/health-gray.svg', - 'x-pack/legacy/plugins/monitoring/public/icons/health-green.svg', - 'x-pack/legacy/plugins/monitoring/public/icons/health-red.svg', - 'x-pack/legacy/plugins/monitoring/public/icons/health-yellow.svg', + 'x-pack/plugins/monitoring/public/components/sparkline/__mocks__/plugins/xpack_main/jquery_flot.js', + 'x-pack/plugins/monitoring/public/icons/health-gray.svg', + 'x-pack/plugins/monitoring/public/icons/health-green.svg', + 'x-pack/plugins/monitoring/public/icons/health-red.svg', + 'x-pack/plugins/monitoring/public/icons/health-yellow.svg', 'x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/pdf/assets/fonts/noto/NotoSansCJKtc-Medium.ttf', 'x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/pdf/assets/fonts/noto/NotoSansCJKtc-Regular.ttf', 'x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/pdf/assets/fonts/roboto/Roboto-Italic.ttf', diff --git a/src/dev/renovate/package_groups.ts b/src/dev/renovate/package_groups.ts index 1bc65fd149f47..9f5aa8556ac21 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/run_check_lockfile_symlinks.js b/src/dev/run_check_lockfile_symlinks.js index 6c6fc54638ee8..b912ea9ddb87e 100644 --- a/src/dev/run_check_lockfile_symlinks.js +++ b/src/dev/run_check_lockfile_symlinks.js @@ -35,9 +35,9 @@ const IGNORE_FILE_GLOBS = [ // fixtures aren't used in production, ignore them '**/*fixtures*/**/*', // cypress isn't used in production, ignore it - 'x-pack/legacy/plugins/apm/e2e/*', + 'x-pack/plugins/apm/e2e/*', // apm scripts aren't used in production, ignore them - 'x-pack/legacy/plugins/apm/scripts/*', + 'x-pack/plugins/apm/scripts/*', ]; run(async ({ log }) => { diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 43114b2edccfc..0e91f0a214a45 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,12 +18,13 @@ */ export const storybookAliases = { - apm: 'x-pack/legacy/plugins/apm/scripts/storybook.js', + 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/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index 01d8a30b598c1..a13f61af60173 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -30,7 +30,7 @@ export const PROJECTS = [ new Project(resolve(REPO_ROOT, 'x-pack/plugins/siem/cypress/tsconfig.json'), { name: 'siem/cypress', }), - new Project(resolve(REPO_ROOT, 'x-pack/legacy/plugins/apm/e2e/tsconfig.json'), { + new Project(resolve(REPO_ROOT, 'x-pack/plugins/apm/e2e/tsconfig.json'), { name: 'apm/cypress', disableTypeCheck: true, }), diff --git a/src/legacy/core_plugins/interpreter/README.md b/src/legacy/core_plugins/interpreter/README.md deleted file mode 100644 index 6d90ce2d5e2eb..0000000000000 --- a/src/legacy/core_plugins/interpreter/README.md +++ /dev/null @@ -1,2 +0,0 @@ -Interpreter legacy plugin has been migrated to the New Platform. Use -`expressions` New Platform plugin instead. diff --git a/src/legacy/core_plugins/interpreter/index.ts b/src/legacy/core_plugins/interpreter/index.ts deleted file mode 100644 index 9427a2f8a2d0f..0000000000000 --- a/src/legacy/core_plugins/interpreter/index.ts +++ /dev/null @@ -1,44 +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 { resolve } from 'path'; -import { Legacy } from '../../../../kibana'; -import { init } from './init'; - -// eslint-disable-next-line -export default function InterpreterPlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'interpreter', - require: ['kibana', 'elasticsearch'], - publicDir: resolve(__dirname, 'public'), - uiExports: { - injectDefaultVars: server => ({ - serverBasePath: server.config().get('server.basePath'), - }), - }, - config: (Joi: any) => { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - init, - }; - - return new kibana.Plugin(config); -} diff --git a/src/legacy/core_plugins/interpreter/init.ts b/src/legacy/core_plugins/interpreter/init.ts deleted file mode 100644 index 46da1539afadb..0000000000000 --- a/src/legacy/core_plugins/interpreter/init.ts +++ /dev/null @@ -1,52 +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. - */ - -/* eslint-disable max-classes-per-file */ - -// @ts-ignore -import { register, registryFactory, Registry, Fn } from '@kbn/interpreter/common'; - -import { Legacy } from '../../../../kibana'; - -export async function init(server: Legacy.Server /* options */) { - server.injectUiAppVars('canvas', () => { - const config = server.config(); - const basePath = config.get('server.basePath'); - const reportingBrowserType = (() => { - const configKey = 'xpack.reporting.capture.browser.type'; - if (!config.has(configKey)) { - return null; - } - return config.get(configKey); - })(); - - return { - kbnIndex: config.get('kibana.index'), - serverFunctions: (server.newPlatform.setup.plugins.expressions as any).__LEGACY - .registries() - .serverFunctions.toArray(), - basePath, - reportingBrowserType, - }; - }); - - // Expose server.plugins.interpreter.register(specs) and - // server.plugins.interpreter.registries() (a getter). - server.expose((server.newPlatform.setup.plugins.expressions as any).__LEGACY); -} diff --git a/src/legacy/core_plugins/interpreter/package.json b/src/legacy/core_plugins/interpreter/package.json deleted file mode 100644 index 3265dadd7fbfc..0000000000000 --- a/src/legacy/core_plugins/interpreter/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "interpreter", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/interpreter/public/canvas/load_legacy_server_function_wrappers.ts b/src/legacy/core_plugins/interpreter/public/canvas/load_legacy_server_function_wrappers.ts deleted file mode 100644 index fed157846a1a1..0000000000000 --- a/src/legacy/core_plugins/interpreter/public/canvas/load_legacy_server_function_wrappers.ts +++ /dev/null @@ -1,33 +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. - */ - -/** - * This file needs to be deleted by 8.0 release. It is here to load available - * server side functions and create a wrappers around them on client side, to - * execute them from client side. This functionality is used only by Canvas - * and all server side functions are in Canvas plugin. - * - * In 8.0 there will be no server-side functions, plugins will register only - * client side functions and if they need those to execute something on the - * server side, it should be respective function's internal implementation detail. - */ - -import { npSetup } from 'ui/new_platform'; - -export const { loadLegacyServerFunctionWrappers } = npSetup.plugins.expressions.__LEGACY; diff --git a/src/legacy/core_plugins/interpreter/public/interpreter.ts b/src/legacy/core_plugins/interpreter/public/interpreter.ts deleted file mode 100644 index 319a2779010c3..0000000000000 --- a/src/legacy/core_plugins/interpreter/public/interpreter.ts +++ /dev/null @@ -1,49 +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 'uiExports/interpreter'; -// @ts-ignore -import { register, registryFactory } from '@kbn/interpreter/common'; -import { npSetup } from 'ui/new_platform'; -import { registries } from './registries'; -import { Executor, ExpressionExecutor } from '../../../../plugins/expressions/public'; - -// Expose kbnInterpreter.register(specs) and kbnInterpreter.registries() globally so that plugins -// can register without a transpile step. -// TODO: This will be left behind in then legacy platform? -(global as any).kbnInterpreter = Object.assign( - (global as any).kbnInterpreter || {}, - registryFactory(registries) -); - -// TODO: This function will be left behind in the legacy platform. -let executorPromise: Promise | undefined; -export const getInterpreter = async () => { - if (!executorPromise) { - const executor = npSetup.plugins.expressions.__LEGACY.getExecutor(); - executorPromise = Promise.resolve(executor); - } - return await executorPromise; -}; - -// TODO: This function will be left behind in the legacy platform. -export const interpretAst: Executor['run'] = async (ast, context, handlers) => { - const { interpreter } = await getInterpreter(); - return await interpreter.interpretAst(ast, context, handlers); -}; diff --git a/src/legacy/core_plugins/interpreter/public/registries.karma_mock.ts b/src/legacy/core_plugins/interpreter/public/registries.karma_mock.ts deleted file mode 100644 index 0f37f33cc1b13..0000000000000 --- a/src/legacy/core_plugins/interpreter/public/registries.karma_mock.ts +++ /dev/null @@ -1,44 +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 sinon from 'sinon'; - -export const functionsRegistry = {}; -export const renderersRegistry = {}; -export const typesRegistry = {}; -export const registries = { - browserFunctions: functionsRegistry, - renderers: renderersRegistry, - types: typesRegistry, - loadLegacyServerFunctionWrappers: () => Promise.resolve(), -}; - -const resetRegistry = (registry: any) => { - registry.wrapper = sinon.stub(); - registry.register = sinon.stub(); - registry.toJS = sinon.stub(); - registry.toArray = sinon.stub(); - registry.get = sinon.stub(); - registry.getProp = sinon.stub(); - registry.reset = sinon.stub(); -}; -const resetAll = () => Object.values(registries).forEach(resetRegistry); - -resetAll(); -afterEach(resetAll); diff --git a/src/legacy/core_plugins/interpreter/public/registries.ts b/src/legacy/core_plugins/interpreter/public/registries.ts deleted file mode 100644 index 63fd9089acf4a..0000000000000 --- a/src/legacy/core_plugins/interpreter/public/registries.ts +++ /dev/null @@ -1,29 +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 { npSetup } from 'ui/new_platform'; - -export const functionsRegistry = npSetup.plugins.expressions.__LEGACY.functions; -export const renderersRegistry = npSetup.plugins.expressions.__LEGACY.renderers; -export const typesRegistry = npSetup.plugins.expressions.__LEGACY.types; -export const registries = { - browserFunctions: functionsRegistry, - renderers: renderersRegistry, - types: typesRegistry, -}; diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 48d86e3628e49..51577456135d1 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -53,7 +53,7 @@ export default function(kibana) { }, uiExports: { - hacks: ['plugins/kibana/discover/legacy', 'plugins/kibana/dev_tools'], + hacks: ['plugins/kibana/dev_tools'], app: { id: 'kibana', title: 'Kibana', diff --git a/src/legacy/core_plugins/kibana/public/discover/__tests__/doc_table/doc_table.js b/src/legacy/core_plugins/kibana/public/__tests__/discover/doc_table.js similarity index 96% rename from src/legacy/core_plugins/kibana/public/discover/__tests__/doc_table/doc_table.js rename to src/legacy/core_plugins/kibana/public/__tests__/discover/doc_table.js index 9e74df08233da..edf65fdb56220 100644 --- a/src/legacy/core_plugins/kibana/public/discover/__tests__/doc_table/doc_table.js +++ b/src/legacy/core_plugins/kibana/public/__tests__/discover/doc_table.js @@ -16,18 +16,15 @@ * specific language governing permissions and limitations * under the License. */ - import angular from 'angular'; import expect from '@kbn/expect'; import _ from 'lodash'; import ngMock from 'ng_mock'; import 'ui/private'; -import { pluginInstance } from 'plugins/kibana/discover/legacy'; +import { pluginInstance } from './legacy'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import hits from 'fixtures/real_hits'; -// Load the kibana app dependencies. - let $parentScope; let $scope; @@ -60,6 +57,7 @@ const destroy = function() { describe('docTable', function() { let $elem; + beforeEach(() => pluginInstance.initializeInnerAngular()); beforeEach(() => pluginInstance.initializeServices()); beforeEach(ngMock.module('app/discover')); diff --git a/src/legacy/core_plugins/kibana/public/discover/__tests__/directives/fixed_scroll.js b/src/legacy/core_plugins/kibana/public/__tests__/discover/fixed_scroll.js similarity index 89% rename from src/legacy/core_plugins/kibana/public/discover/__tests__/directives/fixed_scroll.js rename to src/legacy/core_plugins/kibana/public/__tests__/discover/fixed_scroll.js index 49a0df54079ea..4a8736cc0d6a4 100644 --- a/src/legacy/core_plugins/kibana/public/discover/__tests__/directives/fixed_scroll.js +++ b/src/legacy/core_plugins/kibana/public/__tests__/discover/fixed_scroll.js @@ -17,21 +17,33 @@ * under the License. */ +/* eslint-disable @kbn/eslint/no-restricted-paths */ + +import angular from 'angular'; import expect from '@kbn/expect'; -import { pluginInstance } from 'plugins/kibana/discover/legacy'; import ngMock from 'ng_mock'; import $ from 'jquery'; import sinon from 'sinon'; +import { PrivateProvider } from '../../../../../../plugins/kibana_legacy/public'; +import { FixedScrollProvider } from '../../../../../../plugins/discover/public/application/angular/directives/fixed_scroll'; +import { DebounceProviderTimeout } from '../../../../../../plugins/discover/public/application/angular/directives/debounce/debounce'; + +const testModuleName = 'fixedScroll'; + +angular + .module(testModuleName, []) + .provider('Private', PrivateProvider) + .service('debounce', ['$timeout', DebounceProviderTimeout]) + .directive('fixedScroll', FixedScrollProvider); + describe('FixedScroll directive', function() { const sandbox = sinon.createSandbox(); let compile; let flushPendingTasks; const trash = []; - beforeEach(() => pluginInstance.initializeServices()); - beforeEach(() => pluginInstance.initializeInnerAngular()); - beforeEach(ngMock.module('app/discover')); + beforeEach(ngMock.module(testModuleName)); beforeEach( ngMock.inject(function($compile, $rootScope, $timeout) { flushPendingTasks = function flushPendingTasks() { diff --git a/src/legacy/core_plugins/kibana/public/__tests__/discover/legacy.ts b/src/legacy/core_plugins/kibana/public/__tests__/discover/legacy.ts new file mode 100644 index 0000000000000..ecda2a8c15395 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/__tests__/discover/legacy.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { npSetup, npStart } from 'ui/new_platform'; +import { plugin } from '../../../../../../plugins/discover/public'; +import { coreMock } from '../../../../../../core/public/mocks'; +const context = coreMock.createPluginInitializerContext(); + +export const pluginInstance = plugin(context); +export const setup = pluginInstance.setup(npSetup.core, npSetup.plugins); +export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/kibana/public/__tests__/discover/row_headers.js b/src/legacy/core_plugins/kibana/public/__tests__/discover/row_headers.js new file mode 100644 index 0000000000000..5450a4127b63c --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/__tests__/discover/row_headers.js @@ -0,0 +1,495 @@ +/* + * 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 angular from 'angular'; +import _ from 'lodash'; +import sinon from 'sinon'; +import expect from '@kbn/expect'; +import ngMock from 'ng_mock'; +import { getFakeRow, getFakeRowVals } from 'fixtures/fake_row'; +import $ from 'jquery'; +import { pluginInstance } from './legacy'; +import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; + +describe('Doc Table', function() { + let $parentScope; + let $scope; + + // Stub out a minimal mapping of 4 fields + let mapping; + + let fakeRowVals; + let stubFieldFormatConverter; + beforeEach(() => pluginInstance.initializeServices()); + beforeEach(() => pluginInstance.initializeInnerAngular()); + beforeEach(ngMock.module('app/discover')); + beforeEach( + ngMock.inject(function($rootScope, Private) { + $parentScope = $rootScope; + $parentScope.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); + mapping = $parentScope.indexPattern.fields; + + // Stub `getConverterFor` for a field in the indexPattern to return mock data. + // Returns `val` if provided, otherwise generates fake data for the field. + fakeRowVals = getFakeRowVals('formatted', 0, mapping); + stubFieldFormatConverter = function($root, field, val) { + const convertFn = (value, type, options) => { + if (val) { + return val; + } + const fieldName = _.get(options, 'field.name', null); + + return fakeRowVals[fieldName] || ''; + }; + + $root.indexPattern.fields.getByName(field).format.convert = convertFn; + $root.indexPattern.fields.getByName(field).format.getConverterFor = () => convertFn; + }; + }) + ); + + // Sets up the directive, take an element, and a list of properties to attach to the parent scope. + const init = function($elem, props) { + ngMock.inject(function($compile) { + _.assign($parentScope, props); + $compile($elem)($parentScope); + $elem.scope().$digest(); + $scope = $elem.isolateScope(); + }); + }; + + const destroy = function() { + $scope.$destroy(); + $parentScope.$destroy(); + }; + + // For testing column removing/adding for the header and the rows + const columnTests = function(elemType, parentElem) { + it('should create a time column if the timefield is defined', function() { + const childElems = parentElem.find(elemType); + expect(childElems.length).to.be(1); + }); + + it('should be able to add and remove columns', function() { + let childElems; + + stubFieldFormatConverter($parentScope, 'bytes'); + stubFieldFormatConverter($parentScope, 'request_body'); + + // Should include a column for toggling and the time column by default + $parentScope.columns = ['bytes']; + parentElem.scope().$digest(); + childElems = parentElem.find(elemType); + expect(childElems.length).to.be(2); + expect($(childElems[1]).text()).to.contain('bytes'); + + $parentScope.columns = ['bytes', 'request_body']; + parentElem.scope().$digest(); + childElems = parentElem.find(elemType); + expect(childElems.length).to.be(3); + expect($(childElems[2]).text()).to.contain('request_body'); + + $parentScope.columns = ['request_body']; + parentElem.scope().$digest(); + childElems = parentElem.find(elemType); + expect(childElems.length).to.be(2); + expect($(childElems[1]).text()).to.contain('request_body'); + }); + + it('should create only the toggle column if there is no timeField', function() { + delete parentElem.scope().indexPattern.timeFieldName; + parentElem.scope().$digest(); + + const childElems = parentElem.find(elemType); + expect(childElems.length).to.be(0); + }); + }; + + describe('kbnTableRow', function() { + const $elem = angular.element( + '' + ); + let row; + + beforeEach(function() { + row = getFakeRow(0, mapping); + + init($elem, { + row, + columns: [], + sorting: [], + filter: sinon.spy(), + maxLength: 50, + }); + }); + afterEach(function() { + destroy(); + }); + + describe('adding and removing columns', function() { + columnTests('[data-test-subj~="docTableField"]', $elem); + }); + + describe('details row', function() { + it('should be an empty tr by default', function() { + expect($elem.next().is('tr')).to.be(true); + expect($elem.next().text()).to.be(''); + }); + + it('should expand the detail row when the toggle arrow is clicked', function() { + $elem.children(':first-child').click(); + $scope.$digest(); + expect($elem.next().text()).to.not.be(''); + }); + + describe('expanded', function() { + let $details; + beforeEach(function() { + // Open the row + $scope.toggleRow(); + $scope.$digest(); + $details = $elem.next(); + }); + afterEach(function() { + // Close the row + $scope.toggleRow(); + $scope.$digest(); + }); + + it('should be a tr with something in it', function() { + expect($details.is('tr')).to.be(true); + expect($details.text()).to.not.be.empty(); + }); + }); + }); + }); + + describe('kbnTableRow meta', function() { + const $elem = angular.element( + '' + ); + let row; + + beforeEach(function() { + row = getFakeRow(0, mapping); + + init($elem, { + row: row, + columns: [], + sorting: [], + filtering: sinon.spy(), + maxLength: 50, + }); + + // Open the row + $scope.toggleRow(); + $scope.$digest(); + $elem.next(); + }); + + afterEach(function() { + destroy(); + }); + + /** this no longer works with the new plugin approach + it('should render even when the row source contains a field with the same name as a meta field', function () { + setTimeout(() => { + //this should be overridden by later changes + }, 100); + expect($details.find('tr').length).to.be(_.keys($parentScope.indexPattern.flattenHit($scope.row)).length); + }); */ + }); + + describe('row diffing', function() { + let $row; + let $scope; + let $root; + let $before; + + beforeEach( + ngMock.inject(function($rootScope, $compile, Private) { + $root = $rootScope; + $root.row = getFakeRow(0, mapping); + $root.columns = ['_source']; + $root.sorting = []; + $root.filtering = sinon.spy(); + $root.maxLength = 50; + $root.mapping = mapping; + $root.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); + + // Stub field format converters for every field in the indexPattern + $root.indexPattern.fields.forEach(f => stubFieldFormatConverter($root, f.name)); + + $row = $('').attr({ + 'kbn-table-row': 'row', + columns: 'columns', + sorting: 'sorting', + filtering: 'filtering', + 'index-pattern': 'indexPattern', + }); + + $scope = $root.$new(); + $compile($row)($scope); + $root.$apply(); + + $before = $row.find('td'); + expect($before).to.have.length(3); + expect( + $before + .eq(0) + .text() + .trim() + ).to.be(''); + expect( + $before + .eq(1) + .text() + .trim() + ).to.match(/^time_formatted/); + }) + ); + + afterEach(function() { + $row.remove(); + }); + + it('handles a new column', function() { + $root.columns.push('bytes'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(4); + expect($after[0]).to.be($before[0]); + expect($after[1]).to.be($before[1]); + expect($after[2]).to.be($before[2]); + expect( + $after + .eq(3) + .text() + .trim() + ).to.match(/^bytes_formatted/); + }); + + it('handles two new columns at once', function() { + $root.columns.push('bytes'); + $root.columns.push('request_body'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(5); + expect($after[0]).to.be($before[0]); + expect($after[1]).to.be($before[1]); + expect($after[2]).to.be($before[2]); + expect( + $after + .eq(3) + .text() + .trim() + ).to.match(/^bytes_formatted/); + expect( + $after + .eq(4) + .text() + .trim() + ).to.match(/^request_body_formatted/); + }); + + it('handles three new columns in odd places', function() { + $root.columns = ['@timestamp', 'bytes', '_source', 'request_body']; + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(6); + expect($after[0]).to.be($before[0]); + expect($after[1]).to.be($before[1]); + expect( + $after + .eq(2) + .text() + .trim() + ).to.match(/^@timestamp_formatted/); + expect( + $after + .eq(3) + .text() + .trim() + ).to.match(/^bytes_formatted/); + expect($after[4]).to.be($before[2]); + expect( + $after + .eq(5) + .text() + .trim() + ).to.match(/^request_body_formatted/); + }); + + it('handles a removed column', function() { + _.pull($root.columns, '_source'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(2); + expect($after[0]).to.be($before[0]); + expect($after[1]).to.be($before[1]); + }); + + it('handles two removed columns', function() { + // first add a column + $root.columns.push('@timestamp'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).to.have.length(4); + + $root.columns.pop(); + $root.columns.pop(); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(2); + expect($after[0]).to.be($before[0]); + expect($after[1]).to.be($before[1]); + }); + + it('handles three removed random columns', function() { + // first add two column + $root.columns.push('@timestamp', 'bytes'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).to.have.length(5); + + $root.columns[0] = false; // _source + $root.columns[2] = false; // bytes + $root.columns = $root.columns.filter(Boolean); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(3); + expect($after[0]).to.be($before[0]); + expect($after[1]).to.be($before[1]); + expect( + $after + .eq(2) + .text() + .trim() + ).to.match(/^@timestamp_formatted/); + }); + + it('handles two columns with the same content', function() { + stubFieldFormatConverter($root, 'request_body', fakeRowVals.bytes); + + $root.columns.length = 0; + $root.columns.push('bytes'); + $root.columns.push('request_body'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(4); + expect( + $after + .eq(2) + .text() + .trim() + ).to.match(/^bytes_formatted/); + expect( + $after + .eq(3) + .text() + .trim() + ).to.match(/^bytes_formatted/); + }); + + it('handles two columns swapping position', function() { + $root.columns.push('bytes'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).to.have.length(4); + + $root.columns.reverse(); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(4); + expect($after[0]).to.be($before[0]); + expect($after[1]).to.be($before[1]); + expect($after[2]).to.be($mid[3]); + expect($after[3]).to.be($mid[2]); + }); + + it('handles four columns all reversing position', function() { + $root.columns.push('bytes', 'response', '@timestamp'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).to.have.length(6); + + $root.columns.reverse(); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(6); + expect($after[0]).to.be($before[0]); + expect($after[1]).to.be($before[1]); + expect($after[2]).to.be($mid[5]); + expect($after[3]).to.be($mid[4]); + expect($after[4]).to.be($mid[3]); + expect($after[5]).to.be($mid[2]); + }); + + it('handles multiple columns with the same name', function() { + $root.columns.push('bytes', 'bytes', 'bytes'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).to.have.length(6); + expect($after[0]).to.be($before[0]); + expect($after[1]).to.be($before[1]); + expect($after[2]).to.be($before[2]); + expect( + $after + .eq(3) + .text() + .trim() + ).to.match(/^bytes_formatted/); + expect( + $after + .eq(4) + .text() + .trim() + ).to.match(/^bytes_formatted/); + expect( + $after + .eq(5) + .text() + .trim() + ).to.match(/^bytes_formatted/); + }); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/discover/__tests__/doc_table/lib/rows_headers.js b/src/legacy/core_plugins/kibana/public/discover/__tests__/doc_table/lib/rows_headers.js deleted file mode 100644 index 9b63b8cd18f3e..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/__tests__/doc_table/lib/rows_headers.js +++ /dev/null @@ -1,496 +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 angular from 'angular'; -import _ from 'lodash'; -import sinon from 'sinon'; -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import { getFakeRow, getFakeRowVals } from 'fixtures/fake_row'; -import $ from 'jquery'; -import { pluginInstance } from 'plugins/kibana/discover/legacy'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -describe('Doc Table', function() { - let $parentScope; - let $scope; - - // Stub out a minimal mapping of 4 fields - let mapping; - - let fakeRowVals; - let stubFieldFormatConverter; - beforeEach(() => pluginInstance.initializeServices()); - beforeEach(() => pluginInstance.initializeInnerAngular()); - beforeEach(ngMock.module('app/discover')); - beforeEach( - ngMock.inject(function($rootScope, Private) { - $parentScope = $rootScope; - $parentScope.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - mapping = $parentScope.indexPattern.fields; - - // Stub `getConverterFor` for a field in the indexPattern to return mock data. - // Returns `val` if provided, otherwise generates fake data for the field. - fakeRowVals = getFakeRowVals('formatted', 0, mapping); - stubFieldFormatConverter = function($root, field, val) { - const convertFn = (value, type, options) => { - if (val) { - return val; - } - const fieldName = _.get(options, 'field.name', null); - - return fakeRowVals[fieldName] || ''; - }; - - $root.indexPattern.fields.getByName(field).format.convert = convertFn; - $root.indexPattern.fields.getByName(field).format.getConverterFor = () => convertFn; - }; - }) - ); - - // Sets up the directive, take an element, and a list of properties to attach to the parent scope. - const init = function($elem, props) { - ngMock.inject(function($compile) { - _.assign($parentScope, props); - $compile($elem)($parentScope); - $elem.scope().$digest(); - $scope = $elem.isolateScope(); - }); - }; - - const destroy = function() { - $scope.$destroy(); - $parentScope.$destroy(); - }; - - // For testing column removing/adding for the header and the rows - const columnTests = function(elemType, parentElem) { - it('should create a time column if the timefield is defined', function() { - const childElems = parentElem.find(elemType); - expect(childElems.length).to.be(1); - }); - - it('should be able to add and remove columns', function() { - let childElems; - - stubFieldFormatConverter($parentScope, 'bytes'); - stubFieldFormatConverter($parentScope, 'request_body'); - - // Should include a column for toggling and the time column by default - $parentScope.columns = ['bytes']; - parentElem.scope().$digest(); - childElems = parentElem.find(elemType); - expect(childElems.length).to.be(2); - expect($(childElems[1]).text()).to.contain('bytes'); - - $parentScope.columns = ['bytes', 'request_body']; - parentElem.scope().$digest(); - childElems = parentElem.find(elemType); - expect(childElems.length).to.be(3); - expect($(childElems[2]).text()).to.contain('request_body'); - - $parentScope.columns = ['request_body']; - parentElem.scope().$digest(); - childElems = parentElem.find(elemType); - expect(childElems.length).to.be(2); - expect($(childElems[1]).text()).to.contain('request_body'); - }); - - it('should create only the toggle column if there is no timeField', function() { - delete parentElem.scope().indexPattern.timeFieldName; - parentElem.scope().$digest(); - - const childElems = parentElem.find(elemType); - expect(childElems.length).to.be(0); - }); - }; - - describe('kbnTableRow', function() { - const $elem = angular.element( - '' - ); - let row; - - beforeEach(function() { - row = getFakeRow(0, mapping); - - init($elem, { - row, - columns: [], - sorting: [], - filter: sinon.spy(), - maxLength: 50, - }); - }); - afterEach(function() { - destroy(); - }); - - describe('adding and removing columns', function() { - columnTests('[data-test-subj~="docTableField"]', $elem); - }); - - describe('details row', function() { - it('should be an empty tr by default', function() { - expect($elem.next().is('tr')).to.be(true); - expect($elem.next().text()).to.be(''); - }); - - it('should expand the detail row when the toggle arrow is clicked', function() { - $elem.children(':first-child').click(); - $scope.$digest(); - expect($elem.next().text()).to.not.be(''); - }); - - describe('expanded', function() { - let $details; - beforeEach(function() { - // Open the row - $scope.toggleRow(); - $scope.$digest(); - $details = $elem.next(); - }); - afterEach(function() { - // Close the row - $scope.toggleRow(); - $scope.$digest(); - }); - - it('should be a tr with something in it', function() { - expect($details.is('tr')).to.be(true); - expect($details.text()).to.not.be.empty(); - }); - }); - }); - }); - - describe('kbnTableRow meta', function() { - const $elem = angular.element( - '' - ); - let row; - - beforeEach(function() { - row = getFakeRow(0, mapping); - - init($elem, { - row: row, - columns: [], - sorting: [], - filtering: sinon.spy(), - maxLength: 50, - }); - - // Open the row - $scope.toggleRow(); - $scope.$digest(); - $elem.next(); - }); - - afterEach(function() { - destroy(); - }); - - /** this no longer works with the new plugin approach - it('should render even when the row source contains a field with the same name as a meta field', function () { - setTimeout(() => { - //this should be overridden by later changes - }, 100); - expect($details.find('tr').length).to.be(_.keys($parentScope.indexPattern.flattenHit($scope.row)).length); - }); */ - }); - - describe('row diffing', function() { - let $row; - let $scope; - let $root; - let $before; - - beforeEach( - ngMock.inject(function($rootScope, $compile, Private) { - $root = $rootScope; - $root.row = getFakeRow(0, mapping); - $root.columns = ['_source']; - $root.sorting = []; - $root.filtering = sinon.spy(); - $root.maxLength = 50; - $root.mapping = mapping; - $root.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - - // Stub field format converters for every field in the indexPattern - $root.indexPattern.fields.forEach(f => stubFieldFormatConverter($root, f.name)); - - $row = $('').attr({ - 'kbn-table-row': 'row', - columns: 'columns', - sorting: 'sorting', - filtering: 'filtering', - 'index-pattern': 'indexPattern', - }); - - $scope = $root.$new(); - $compile($row)($scope); - $root.$apply(); - - $before = $row.find('td'); - expect($before).to.have.length(3); - expect( - $before - .eq(0) - .text() - .trim() - ).to.be(''); - expect( - $before - .eq(1) - .text() - .trim() - ).to.match(/^time_formatted/); - }) - ); - - afterEach(function() { - $row.remove(); - }); - - it('handles a new column', function() { - $root.columns.push('bytes'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(4); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($before[2]); - expect( - $after - .eq(3) - .text() - .trim() - ).to.match(/^bytes_formatted/); - }); - - it('handles two new columns at once', function() { - $root.columns.push('bytes'); - $root.columns.push('request_body'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(5); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($before[2]); - expect( - $after - .eq(3) - .text() - .trim() - ).to.match(/^bytes_formatted/); - expect( - $after - .eq(4) - .text() - .trim() - ).to.match(/^request_body_formatted/); - }); - - it('handles three new columns in odd places', function() { - $root.columns = ['@timestamp', 'bytes', '_source', 'request_body']; - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(6); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect( - $after - .eq(2) - .text() - .trim() - ).to.match(/^@timestamp_formatted/); - expect( - $after - .eq(3) - .text() - .trim() - ).to.match(/^bytes_formatted/); - expect($after[4]).to.be($before[2]); - expect( - $after - .eq(5) - .text() - .trim() - ).to.match(/^request_body_formatted/); - }); - - it('handles a removed column', function() { - _.pull($root.columns, '_source'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(2); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - }); - - it('handles two removed columns', function() { - // first add a column - $root.columns.push('@timestamp'); - $root.$apply(); - - const $mid = $row.find('td'); - expect($mid).to.have.length(4); - - $root.columns.pop(); - $root.columns.pop(); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(2); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - }); - - it('handles three removed random columns', function() { - // first add two column - $root.columns.push('@timestamp', 'bytes'); - $root.$apply(); - - const $mid = $row.find('td'); - expect($mid).to.have.length(5); - - $root.columns[0] = false; // _source - $root.columns[2] = false; // bytes - $root.columns = $root.columns.filter(Boolean); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(3); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect( - $after - .eq(2) - .text() - .trim() - ).to.match(/^@timestamp_formatted/); - }); - - it('handles two columns with the same content', function() { - stubFieldFormatConverter($root, 'request_body', fakeRowVals.bytes); - - $root.columns.length = 0; - $root.columns.push('bytes'); - $root.columns.push('request_body'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(4); - expect( - $after - .eq(2) - .text() - .trim() - ).to.match(/^bytes_formatted/); - expect( - $after - .eq(3) - .text() - .trim() - ).to.match(/^bytes_formatted/); - }); - - it('handles two columns swapping position', function() { - $root.columns.push('bytes'); - $root.$apply(); - - const $mid = $row.find('td'); - expect($mid).to.have.length(4); - - $root.columns.reverse(); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(4); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($mid[3]); - expect($after[3]).to.be($mid[2]); - }); - - it('handles four columns all reversing position', function() { - $root.columns.push('bytes', 'response', '@timestamp'); - $root.$apply(); - - const $mid = $row.find('td'); - expect($mid).to.have.length(6); - - $root.columns.reverse(); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(6); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($mid[5]); - expect($after[3]).to.be($mid[4]); - expect($after[4]).to.be($mid[3]); - expect($after[5]).to.be($mid[2]); - }); - - it('handles multiple columns with the same name', function() { - $root.columns.push('bytes', 'bytes', 'bytes'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(6); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($before[2]); - expect( - $after - .eq(3) - .text() - .trim() - ).to.match(/^bytes_formatted/); - expect( - $after - .eq(4) - .text() - .trim() - ).to.match(/^bytes_formatted/); - expect( - $after - .eq(5) - .text() - .trim() - ).to.match(/^bytes_formatted/); - }); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/discover/_index.scss b/src/legacy/core_plugins/kibana/public/discover/_index.scss deleted file mode 100644 index 386472a9f6e01..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -// Discover plugin styles -@import 'np_ready/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/index.ts b/src/legacy/core_plugins/kibana/public/discover/index.ts deleted file mode 100644 index b449b70418d02..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/index.ts +++ /dev/null @@ -1,26 +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 { PluginInitializerContext } from 'kibana/public'; -import { DiscoverPlugin } from './plugin'; - -// Core will be looking for this when loading our plugin in the new platform -export const plugin = (context: PluginInitializerContext) => { - return new DiscoverPlugin(); -}; diff --git a/src/legacy/core_plugins/kibana/public/discover/legacy.ts b/src/legacy/core_plugins/kibana/public/discover/legacy.ts deleted file mode 100644 index f08fd22c71850..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/legacy.ts +++ /dev/null @@ -1,27 +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 { PluginInitializerContext } from 'kibana/public'; -import { npSetup, npStart } from 'ui/new_platform'; -import { plugin } from './index'; - -// Legacy compatibility part - to be removed at cutover, replaced by a kibana.json file -export const pluginInstance = plugin({} as PluginInitializerContext); -export const setup = pluginInstance.setup(npSetup.core, npSetup.plugins); -export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js deleted file mode 100644 index efc230d2cd4ae..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js +++ /dev/null @@ -1,192 +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 _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { getServices } from '../../../../kibana_services'; - -import { fetchAnchorProvider } from '../api/anchor'; -import { fetchContextProvider } from '../api/context'; -import { getQueryParameterActions } from '../query_parameters'; -import { FAILURE_REASONS, LOADING_STATUS } from './constants'; -import { MarkdownSimple } from '../../../../../../../../../plugins/kibana_react/public'; - -export function QueryActionsProvider(Promise) { - const { filterManager, indexPatterns, data } = getServices(); - const fetchAnchor = fetchAnchorProvider(indexPatterns, data.search.searchSource.create()); - const { fetchSurroundingDocs } = fetchContextProvider(indexPatterns); - const { setPredecessorCount, setQueryParameters, setSuccessorCount } = getQueryParameterActions( - filterManager, - indexPatterns - ); - - const setFailedStatus = state => (subject, details = {}) => - (state.loadingStatus[subject] = { - status: LOADING_STATUS.FAILED, - reason: FAILURE_REASONS.UNKNOWN, - ...details, - }); - - const setLoadedStatus = state => subject => - (state.loadingStatus[subject] = { - status: LOADING_STATUS.LOADED, - }); - - const setLoadingStatus = state => subject => - (state.loadingStatus[subject] = { - status: LOADING_STATUS.LOADING, - }); - - const fetchAnchorRow = state => () => { - const { - queryParameters: { indexPatternId, anchorId, sort, tieBreakerField }, - } = state; - - if (!tieBreakerField) { - return Promise.reject( - setFailedStatus(state)('anchor', { - reason: FAILURE_REASONS.INVALID_TIEBREAKER, - }) - ); - } - - setLoadingStatus(state)('anchor'); - - return Promise.try(() => - fetchAnchor(indexPatternId, anchorId, [_.zipObject([sort]), { [tieBreakerField]: sort[1] }]) - ).then( - anchorDocument => { - setLoadedStatus(state)('anchor'); - state.rows.anchor = anchorDocument; - return anchorDocument; - }, - error => { - setFailedStatus(state)('anchor', { error }); - getServices().toastNotifications.addDanger({ - title: i18n.translate('kbn.context.unableToLoadAnchorDocumentDescription', { - defaultMessage: 'Unable to load the anchor document', - }), - text: {error.message}, - }); - throw error; - } - ); - }; - - const fetchSurroundingRows = (type, state) => { - const { - queryParameters: { indexPatternId, sort, tieBreakerField }, - rows: { anchor }, - } = state; - const filters = getServices().filterManager.getFilters(); - - const count = - type === 'successors' - ? state.queryParameters.successorCount - : state.queryParameters.predecessorCount; - - if (!tieBreakerField) { - return Promise.reject( - setFailedStatus(state)(type, { - reason: FAILURE_REASONS.INVALID_TIEBREAKER, - }) - ); - } - - setLoadingStatus(state)(type); - const [sortField, sortDir] = sort; - - return Promise.try(() => - fetchSurroundingDocs( - type, - indexPatternId, - anchor, - sortField, - tieBreakerField, - sortDir, - count, - filters - ) - ).then( - documents => { - setLoadedStatus(state)(type); - state.rows[type] = documents; - return documents; - }, - error => { - setFailedStatus(state)(type, { error }); - getServices().toastNotifications.addDanger({ - title: i18n.translate('kbn.context.unableToLoadDocumentDescription', { - defaultMessage: 'Unable to load documents', - }), - text: {error.message}, - }); - throw error; - } - ); - }; - - const fetchContextRows = state => () => - Promise.all([ - fetchSurroundingRows('predecessors', state), - fetchSurroundingRows('successors', state), - ]); - - const fetchAllRows = state => () => - Promise.try(fetchAnchorRow(state)).then(fetchContextRows(state)); - - const fetchContextRowsWithNewQueryParameters = state => queryParameters => { - setQueryParameters(state)(queryParameters); - return fetchContextRows(state)(); - }; - - const fetchAllRowsWithNewQueryParameters = state => queryParameters => { - setQueryParameters(state)(queryParameters); - return fetchAllRows(state)(); - }; - - const fetchGivenPredecessorRows = state => count => { - setPredecessorCount(state)(count); - return fetchSurroundingRows('predecessors', state); - }; - - const fetchGivenSuccessorRows = state => count => { - setSuccessorCount(state)(count); - return fetchSurroundingRows('successors', state); - }; - - const setAllRows = state => (predecessorRows, anchorRow, successorRows) => - (state.rows.all = [ - ...(predecessorRows || []), - ...(anchorRow ? [anchorRow] : []), - ...(successorRows || []), - ]); - - return { - fetchAllRows, - fetchAllRowsWithNewQueryParameters, - fetchAnchorRow, - fetchContextRows, - fetchContextRowsWithNewQueryParameters, - fetchGivenPredecessorRows, - fetchGivenSuccessorRows, - setAllRows, - }; -} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/actions.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/actions.js deleted file mode 100644 index 5c1700e776361..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/actions.js +++ /dev/null @@ -1,74 +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 _ from 'lodash'; -import { esFilters } from '../../../../../../../../../plugins/data/public'; - -import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE, QUERY_PARAMETER_KEYS } from './constants'; - -export function getQueryParameterActions(filterManager, indexPatterns) { - const setPredecessorCount = state => predecessorCount => - (state.queryParameters.predecessorCount = clamp( - MIN_CONTEXT_SIZE, - MAX_CONTEXT_SIZE, - predecessorCount - )); - - const setSuccessorCount = state => successorCount => - (state.queryParameters.successorCount = clamp( - MIN_CONTEXT_SIZE, - MAX_CONTEXT_SIZE, - successorCount - )); - - const setQueryParameters = state => queryParameters => - Object.assign(state.queryParameters, _.pick(queryParameters, QUERY_PARAMETER_KEYS)); - - const updateFilters = () => filters => { - filterManager.setFilters(filters); - }; - - const addFilter = state => async (field, values, operation) => { - const indexPatternId = state.queryParameters.indexPatternId; - const newFilters = esFilters.generateFilters( - filterManager, - field, - values, - operation, - indexPatternId - ); - filterManager.addFilters(newFilters); - if (indexPatterns) { - const indexPattern = await indexPatterns.get(indexPatternId); - indexPattern.popularizeField(field.name, 1); - } - }; - - return { - addFilter, - updateFilters, - setPredecessorCount, - setQueryParameters, - setSuccessorCount, - }; -} - -function clamp(minimum, maximum, value) { - return Math.max(Math.min(maximum, value), minimum); -} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/debounce/__tests__/debounce.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/debounce/__tests__/debounce.js deleted file mode 100644 index 43fa5ffbf299a..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/debounce/__tests__/debounce.js +++ /dev/null @@ -1,167 +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 sinon from 'sinon'; -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import { DebounceProvider } from '../index'; -import { pluginInstance } from 'plugins/kibana/discover/legacy'; - -let debounce; -let debounceFromProvider; -let $timeout; - -function init() { - pluginInstance.initializeServices(); - pluginInstance.initializeInnerAngular(); - ngMock.module('app/discover'); - - ngMock.inject(function($injector, _$timeout_, Private) { - $timeout = _$timeout_; - - debounce = $injector.get('debounce'); - debounceFromProvider = Private(DebounceProvider); - }); -} - -describe('debounce service', function() { - let spy; - beforeEach(function() { - spy = sinon.spy(); - init(); - }); - - it('should have a cancel method', function() { - const bouncer = debounce(() => {}, 100); - const bouncerFromProvider = debounceFromProvider(() => {}, 100); - - expect(bouncer).to.have.property('cancel'); - expect(bouncerFromProvider).to.have.property('cancel'); - }); - - describe('delayed execution', function() { - const sandbox = sinon.createSandbox(); - - beforeEach(() => sandbox.useFakeTimers()); - afterEach(() => sandbox.restore()); - - it('should delay execution', function() { - const bouncer = debounce(spy, 100); - const bouncerFromProvider = debounceFromProvider(spy, 100); - - bouncer(); - sinon.assert.notCalled(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); - - spy.resetHistory(); - - bouncerFromProvider(); - sinon.assert.notCalled(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); - }); - - it('should fire on leading edge', function() { - const bouncer = debounce(spy, 100, { leading: true }); - const bouncerFromProvider = debounceFromProvider(spy, 100, { leading: true }); - - bouncer(); - sinon.assert.calledOnce(spy); - $timeout.flush(); - sinon.assert.calledTwice(spy); - - spy.resetHistory(); - - bouncerFromProvider(); - sinon.assert.calledOnce(spy); - $timeout.flush(); - sinon.assert.calledTwice(spy); - }); - - it('should only fire on leading edge', function() { - const bouncer = debounce(spy, 100, { leading: true, trailing: false }); - const bouncerFromProvider = debounceFromProvider(spy, 100, { - leading: true, - trailing: false, - }); - - bouncer(); - sinon.assert.calledOnce(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); - - spy.resetHistory(); - - bouncerFromProvider(); - sinon.assert.calledOnce(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); - }); - - it('should reset delayed execution', function() { - const cancelSpy = sinon.spy($timeout, 'cancel'); - const bouncer = debounce(spy, 100); - const bouncerFromProvider = debounceFromProvider(spy, 100); - - bouncer(); - sandbox.clock.tick(1); - - bouncer(); - sinon.assert.notCalled(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); - sinon.assert.calledOnce(cancelSpy); - - spy.resetHistory(); - cancelSpy.resetHistory(); - - bouncerFromProvider(); - sandbox.clock.tick(1); - - bouncerFromProvider(); - sinon.assert.notCalled(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); - sinon.assert.calledOnce(cancelSpy); - }); - }); - - describe('cancel', function() { - it('should cancel the $timeout', function() { - const cancelSpy = sinon.spy($timeout, 'cancel'); - const bouncer = debounce(spy, 100); - const bouncerFromProvider = debounceFromProvider(spy, 100); - - bouncer(); - bouncer.cancel(); - sinon.assert.calledOnce(cancelSpy); - // throws if pending timeouts - $timeout.verifyNoPendingTasks(); - - cancelSpy.resetHistory(); - - bouncerFromProvider(); - bouncerFromProvider.cancel(); - sinon.assert.calledOnce(cancelSpy); - // throws if pending timeouts - $timeout.verifyNoPendingTasks(); - }); - }); -}); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/cell.html b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/cell.html deleted file mode 100644 index 0704016a52bbd..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/cell.html +++ /dev/null @@ -1,37 +0,0 @@ -<% -var attributes = ''; -if (timefield) { - attributes='class="eui-textNoWrap" width="1%"'; -} else if (sourcefield) { - attributes='class="eui-textBreakAll eui-textBreakWord"'; -} else { - attributes='class="kbnDocTableCell__dataField eui-textBreakAll eui-textBreakWord"'; -} -%> - data-test-subj="docTableField"> - <%= formatted %> - - <% if (filterable) { %> - - - <% } %> - - diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/open.html b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/open.html deleted file mode 100644 index d6c4b858d2b47..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/open.html +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx deleted file mode 100644 index 90e061ac1aa05..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import { getServices } from '../../kibana_services'; - -export function createDocViewerDirective(reactDirective: any) { - return reactDirective( - (props: any) => { - const { DocViewer } = getServices(); - return ; - }, - [ - 'hit', - ['indexPattern', { watchDepth: 'reference' }], - ['filter', { watchDepth: 'reference' }], - ['columns', { watchDepth: 'collection' }], - ['onAddColumn', { watchDepth: 'reference' }], - ['onRemoveColumn', { watchDepth: 'reference' }], - ], - { - restrict: 'E', - scope: { - hit: '=', - indexPattern: '=', - filter: '=?', - columns: '=?', - onAddColumn: '=?', - onRemoveColumn: '=?', - }, - } - ); -} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss deleted file mode 100644 index 6ddd2e0eae8e3..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import 'fetch_error/index'; -@import 'sidebar/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_field_type_name.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_field_type_name.ts deleted file mode 100644 index 0cf428ee48b9d..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_field_type_name.ts +++ /dev/null @@ -1,73 +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 { i18n } from '@kbn/i18n'; - -export function getFieldTypeName(type: string) { - switch (type) { - case 'boolean': - return i18n.translate('kbn.discover.fieldNameIcons.booleanAriaLabel', { - defaultMessage: 'Boolean field', - }); - case 'conflict': - return i18n.translate('kbn.discover.fieldNameIcons.conflictFieldAriaLabel', { - defaultMessage: 'Conflicting field', - }); - case 'date': - return i18n.translate('kbn.discover.fieldNameIcons.dateFieldAriaLabel', { - defaultMessage: 'Date field', - }); - case 'geo_point': - return i18n.translate('kbn.discover.fieldNameIcons.geoPointFieldAriaLabel', { - defaultMessage: 'Geo point field', - }); - case 'geo_shape': - return i18n.translate('kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel', { - defaultMessage: 'Geo shape field', - }); - case 'ip': - return i18n.translate('kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel', { - defaultMessage: 'IP address field', - }); - case 'murmur3': - return i18n.translate('kbn.discover.fieldNameIcons.murmur3FieldAriaLabel', { - defaultMessage: 'Murmur3 field', - }); - case 'number': - return i18n.translate('kbn.discover.fieldNameIcons.numberFieldAriaLabel', { - defaultMessage: 'Number field', - }); - case 'source': - // Note that this type is currently not provided, type for _source is undefined - return i18n.translate('kbn.discover.fieldNameIcons.sourceFieldAriaLabel', { - defaultMessage: 'Source field', - }); - case 'string': - return i18n.translate('kbn.discover.fieldNameIcons.stringFieldAriaLabel', { - defaultMessage: 'String field', - }); - case 'nested': - return i18n.translate('kbn.discover.fieldNameIcons.nestedFieldAriaLabel', { - defaultMessage: 'Nested field', - }); - default: - return i18n.translate('kbn.discover.fieldNameIcons.unknownFieldAriaLabel', { - defaultMessage: 'Unknown field', - }); - } -} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/index.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/index.ts deleted file mode 100644 index 3138008f3e3a0..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/index.ts +++ /dev/null @@ -1,22 +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. - */ - -export * from './types'; -export * from './search_embeddable_factory'; -export * from './search_embeddable'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/types.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/types.ts deleted file mode 100644 index b20e9b2faf7c4..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/types.ts +++ /dev/null @@ -1,42 +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 { EmbeddableInput, EmbeddableOutput, IEmbeddable } from 'src/plugins/embeddable/public'; -import { SortOrder } from '../angular/doc_table/components/table_header/helpers'; -import { Filter, IIndexPattern, TimeRange, Query } from '../../../../../../../plugins/data/public'; -import { SavedSearch } from '../../../../../../../plugins/discover/public'; - -export interface SearchInput extends EmbeddableInput { - timeRange: TimeRange; - query?: Query; - filters?: Filter[]; - hidePanelTitles?: boolean; - columns?: string[]; - sort?: SortOrder[]; -} - -export interface SearchOutput extends EmbeddableOutput { - editUrl: string; - indexPatterns?: IIndexPattern[]; - editable: boolean; -} - -export interface ISearchEmbeddable extends IEmbeddable { - getSavedSearch(): SavedSearch; -} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/breadcrumbs.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/breadcrumbs.ts deleted file mode 100644 index 6c3856932c96c..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/breadcrumbs.ts +++ /dev/null @@ -1,40 +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 { i18n } from '@kbn/i18n'; - -export function getRootBreadcrumbs() { - return [ - { - text: i18n.translate('kbn.discover.rootBreadcrumb', { - defaultMessage: 'Discover', - }), - href: '#/discover', - }, - ]; -} - -export function getSavedSearchBreadcrumbs($route: any) { - return [ - ...getRootBreadcrumbs(), - { - text: $route.current.locals.savedObjects.savedSearch.id, - }, - ]; -} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/register_feature.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/register_feature.ts deleted file mode 100644 index 74255642ab2c9..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/register_feature.ts +++ /dev/null @@ -1,39 +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 { i18n } from '@kbn/i18n'; -import { - FeatureCatalogueCategory, - HomePublicPluginSetup, -} from '../../../../../../plugins/home/public'; - -export function registerFeature(home: HomePublicPluginSetup) { - home.featureCatalogue.register({ - id: 'discover', - title: i18n.translate('kbn.discover.discoverTitle', { - defaultMessage: 'Discover', - }), - description: i18n.translate('kbn.discover.discoverDescription', { - defaultMessage: 'Interactively explore your data by querying and filtering raw documents.', - }), - icon: 'discoverApp', - path: '/app/kibana#/discover', - showOnHomePage: true, - category: FeatureCatalogueCategory.DATA, - }); -} diff --git a/src/legacy/core_plugins/kibana/public/discover/plugin.ts b/src/legacy/core_plugins/kibana/public/discover/plugin.ts deleted file mode 100644 index 702331529b879..0000000000000 --- a/src/legacy/core_plugins/kibana/public/discover/plugin.ts +++ /dev/null @@ -1,236 +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 { BehaviorSubject } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; -import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'kibana/public'; -import angular, { auto } from 'angular'; -import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; -import { - DataPublicPluginStart, - DataPublicPluginSetup, - esFilters, -} from '../../../../../plugins/data/public'; -import { registerFeature } from './np_ready/register_feature'; -import './kibana_services'; -import { EmbeddableStart, EmbeddableSetup } from '../../../../../plugins/embeddable/public'; -import { getInnerAngularModule, getInnerAngularModuleEmbeddable } from './get_inner_angular'; -import { getHistory, setAngularModule, setServices, setUrlTracker } from './kibana_services'; -import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public'; -import { ChartsPluginStart } from '../../../../../plugins/charts/public'; -import { buildServices } from './build_services'; -import { SharePluginStart } from '../../../../../plugins/share/public'; -import { - KibanaLegacySetup, - AngularRenderedAppUpdater, -} from '../../../../../plugins/kibana_legacy/public'; -import { DiscoverSetup, DiscoverStart } from '../../../../../plugins/discover/public'; -import { HomePublicPluginSetup } from '../../../../../plugins/home/public'; -import { - VisualizationsStart, - VisualizationsSetup, -} from '../../../../../plugins/visualizations/public'; -import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; - -export interface DiscoverSetupPlugins { - uiActions: UiActionsSetup; - embeddable: EmbeddableSetup; - kibanaLegacy: KibanaLegacySetup; - home: HomePublicPluginSetup; - visualizations: VisualizationsSetup; - data: DataPublicPluginSetup; - discover: DiscoverSetup; -} -export interface DiscoverStartPlugins { - uiActions: UiActionsStart; - embeddable: EmbeddableStart; - navigation: NavigationStart; - charts: ChartsPluginStart; - data: DataPublicPluginStart; - share: SharePluginStart; - inspector: any; - visualizations: VisualizationsStart; - discover: DiscoverStart; -} -const innerAngularName = 'app/discover'; -const embeddableAngularName = 'app/discoverEmbeddable'; - -/** - * Contains Discover, one of the oldest parts of Kibana - * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular - * Discover provides embeddables, those contain a slimmer Angular - */ -export class DiscoverPlugin implements Plugin { - private servicesInitialized: boolean = false; - private innerAngularInitialized: boolean = false; - private embeddableInjector: auto.IInjectorService | null = null; - private getEmbeddableInjector: (() => Promise) | null = null; - private appStateUpdater = new BehaviorSubject(() => ({})); - private stopUrlTracking: (() => void) | undefined = undefined; - - /** - * why are those functions public? they are needed for some mocha tests - * can be removed once all is Jest - */ - public initializeInnerAngular?: () => void; - public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; - - setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { - const { - appMounted, - appUnMounted, - stop: stopUrlTracker, - setActiveUrl: setTrackedUrl, - } = createKbnUrlTracker({ - // we pass getter here instead of plain `history`, - // so history is lazily created (when app is mounted) - // this prevents redundant `#` when not in discover app - getHistory, - baseUrl: core.http.basePath.prepend('/app/kibana'), - defaultSubUrl: '#/discover', - storageKey: `lastUrl:${core.http.basePath.get()}:discover`, - navLinkUpdater$: this.appStateUpdater, - toastNotifications: core.notifications.toasts, - stateParams: [ - { - kbnUrlKey: '_g', - stateUpdate$: plugins.data.query.state$.pipe( - filter( - ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) - ), - map(({ state }) => ({ - ...state, - filters: state.filters?.filter(esFilters.isFilterPinned), - })) - ), - }, - ], - }); - setUrlTracker({ setTrackedUrl }); - this.stopUrlTracking = () => { - stopUrlTracker(); - }; - - this.getEmbeddableInjector = this.getInjector.bind(this); - plugins.discover.docViews.setAngularInjectorGetter(this.getEmbeddableInjector); - plugins.kibanaLegacy.registerLegacyApp({ - id: 'discover', - title: 'Discover', - updater$: this.appStateUpdater.asObservable(), - navLinkId: 'kibana:discover', - order: -1004, - euiIconType: 'discoverApp', - mount: async (params: AppMountParameters) => { - if (!this.initializeServices) { - throw Error('Discover plugin method initializeServices is undefined'); - } - if (!this.initializeInnerAngular) { - throw Error('Discover plugin method initializeInnerAngular is undefined'); - } - appMounted(); - await this.initializeServices(); - await this.initializeInnerAngular(); - - // make sure the index pattern list is up to date - const [, { data: dataStart }] = await core.getStartServices(); - await dataStart.indexPatterns.clearCache(); - const { renderApp } = await import('./np_ready/application'); - const unmount = await renderApp(innerAngularName, params.element); - return () => { - unmount(); - appUnMounted(); - }; - }, - }); - registerFeature(plugins.home); - this.registerEmbeddable(core, plugins); - } - - start(core: CoreStart, plugins: DiscoverStartPlugins) { - // we need to register the application service at setup, but to render it - // there are some start dependencies necessary, for this reason - // initializeInnerAngular + initializeServices are assigned at start and used - // when the application/embeddable is mounted - this.initializeInnerAngular = async () => { - if (this.innerAngularInitialized) { - return; - } - // this is used by application mount and tests - const module = getInnerAngularModule(innerAngularName, core, plugins); - setAngularModule(module); - this.innerAngularInitialized = true; - }; - - this.initializeServices = async () => { - if (this.servicesInitialized) { - return { core, plugins }; - } - const services = await buildServices(core, plugins, getHistory); - setServices(services); - this.servicesInitialized = true; - - return { core, plugins }; - }; - } - - stop() { - if (this.stopUrlTracking) { - this.stopUrlTracking(); - } - } - - /** - * register embeddable with a slimmer embeddable version of inner angular - */ - private async registerEmbeddable( - core: CoreSetup, - plugins: DiscoverSetupPlugins - ) { - const { SearchEmbeddableFactory } = await import('./np_ready/embeddable'); - - if (!this.getEmbeddableInjector) { - throw Error('Discover plugin method getEmbeddableInjector is undefined'); - } - - const getStartServices = async () => { - const [coreStart, deps] = await core.getStartServices(); - return { - executeTriggerActions: deps.uiActions.executeTriggerActions, - isEditable: () => coreStart.application.capabilities.discover.save as boolean, - }; - }; - - const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector); - plugins.embeddable.registerEmbeddableFactory(factory.type, factory); - } - - private async getInjector() { - if (!this.embeddableInjector) { - if (!this.initializeServices) { - throw Error('Discover plugin getEmbeddableInjector: initializeServices is undefined'); - } - const { core, plugins } = await this.initializeServices(); - getInnerAngularModuleEmbeddable(embeddableAngularName, core, plugins); - const mountpoint = document.createElement('div'); - this.embeddableInjector = angular.bootstrap(mountpoint, [embeddableAngularName]); - } - - return this.embeddableInjector; - } -} 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 22136049b494a..0000000000000 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 a93c83b4b4ae0..0000000000000 --- 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 78db57f914818..0000000000000 --- 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/legacy/core_plugins/kibana/public/index.scss b/src/legacy/core_plugins/kibana/public/index.scss index 26805554370b9..6a2e65e3a9ff5 100644 --- a/src/legacy/core_plugins/kibana/public/index.scss +++ b/src/legacy/core_plugins/kibana/public/index.scss @@ -7,9 +7,6 @@ // Public UI styles @import 'src/legacy/ui/public/index'; -// Discover styles -@import 'discover/index'; - // Has to come after visualize because of some // bad cascading in the Editor layout @import '../../../../plugins/maps_legacy/public/index'; diff --git a/src/legacy/core_plugins/kibana/public/kibana.js b/src/legacy/core_plugins/kibana/public/kibana.js index ea0d5ad3790b1..ad67a74121cc9 100644 --- a/src/legacy/core_plugins/kibana/public/kibana.js +++ b/src/legacy/core_plugins/kibana/public/kibana.js @@ -42,7 +42,6 @@ import 'uiExports/shareContextMenuExtensions'; import 'uiExports/interpreter'; import 'ui/autoload/all'; -import './discover/legacy'; import './management'; import './dev_tools'; import { showAppRedirectNotification } from '../../../../plugins/kibana_legacy/public'; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap index ed65db10e0acb..1545ab8cb9b1c 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/__snapshots__/create_index_pattern_wizard.test.tsx.snap @@ -149,6 +149,8 @@ exports[`CreateIndexPatternWizard renders time field step when step is set to 2 indexPatternsService={ Object { "clearCache": [MockFunction], + "createField": [MockFunction], + "createFieldList": [MockFunction], "ensureDefaultIndexPattern": [MockFunction], "get": [MockFunction], "make": [Function], diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.tsx b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.tsx index 564f115cf2c48..3b865f7d5e1ea 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.tsx +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/create_edit_field/create_edit_field.tsx @@ -24,8 +24,8 @@ import { FieldEditor } from 'ui/field_editor'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { HttpStart, DocLinksStart } from 'src/core/public'; +import { IndexPattern, DataPublicPluginStart } from 'src/plugins/data/public'; import { IndexHeader } from '../index_header'; -import { IndexPattern, IndexPatternField } from '../../../../../../../../../plugins/data/public'; import { ChromeDocTitle, NotificationsStart } from '../../../../../../../../../core/public'; import { TAB_SCRIPTED_FIELDS, TAB_INDEXED_FIELDS } from '../constants'; @@ -36,6 +36,7 @@ interface CreateEditFieldProps extends RouteComponentProps { fieldFormatEditors: any; getConfig: (name: string) => any; services: { + dataStart: DataPublicPluginStart; notifications: NotificationsStart; docTitle: ChromeDocTitle; getHttpStart: () => HttpStart; @@ -63,10 +64,14 @@ export const CreateEditField = withRouter( const field = mode === 'edit' && fieldName ? indexPattern.fields.getByName(fieldName) - : new IndexPatternField(indexPattern, { - scripted: true, - type: 'number', - }); + : services.dataStart.indexPatterns.createField( + indexPattern, + { + scripted: true, + type: 'number', + }, + false + ); const url = `/management/kibana/index_patterns/${indexPattern.id}`; diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index.js index e2f387c0291a7..ab1fa546e5ea8 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/index.js @@ -112,6 +112,7 @@ const renderCreateEditField = ($scope, $route, getConfig, fieldFormatEditors) => fieldFormatEditors={fieldFormatEditors} getConfig={getConfig} services={{ + dataStart: npStart.plugins.data, getHttpStart: () => npStart.core.http, notifications: npStart.core.notifications, docTitle: npStart.core.chrome.docTitle, diff --git a/src/legacy/server/config/config.js b/src/legacy/server/config/config.js index c31ded608dd31..b186071edeaf7 100644 --- a/src/legacy/server/config/config.js +++ b/src/legacy/server/config/config.js @@ -19,7 +19,7 @@ import Joi from 'joi'; import _ from 'lodash'; -import override from './override'; +import { override } from './override'; import createDefaultSchema from './schema'; import { unset, deepCloneWithBuffers as clone, IS_KIBANA_DISTRIBUTABLE } from '../../utils'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths diff --git a/src/legacy/server/config/explode_by.js b/src/legacy/server/config/explode_by.js deleted file mode 100644 index 46347feca550d..0000000000000 --- a/src/legacy/server/config/explode_by.js +++ /dev/null @@ -1,37 +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 _ from 'lodash'; - -export default function(dot, flatObject) { - const fullObject = {}; - _.each(flatObject, function(value, key) { - const keys = key.split(dot); - (function walk(memo, keys, value) { - const _key = keys.shift(); - if (keys.length === 0) { - memo[_key] = value; - } else { - if (!memo[_key]) memo[_key] = {}; - walk(memo[_key], keys, value); - } - })(fullObject, keys, value); - }); - return fullObject; -} diff --git a/src/legacy/server/config/explode_by.test.js b/src/legacy/server/config/explode_by.test.js deleted file mode 100644 index 741edba27d325..0000000000000 --- a/src/legacy/server/config/explode_by.test.js +++ /dev/null @@ -1,48 +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 explodeBy from './explode_by'; - -describe('explode_by(dot, flatObject)', function() { - it('should explode a flatten object with dots', function() { - const flatObject = { - 'test.enable': true, - 'test.hosts': ['host-01', 'host-02'], - }; - expect(explodeBy('.', flatObject)).toEqual({ - test: { - enable: true, - hosts: ['host-01', 'host-02'], - }, - }); - }); - - it('should explode a flatten object with slashes', function() { - const flatObject = { - 'test/enable': true, - 'test/hosts': ['host-01', 'host-02'], - }; - expect(explodeBy('/', flatObject)).toEqual({ - test: { - enable: true, - hosts: ['host-01', 'host-02'], - }, - }); - }); -}); diff --git a/src/legacy/server/config/override.js b/src/legacy/server/config/override.js deleted file mode 100644 index bab9387ac006f..0000000000000 --- a/src/legacy/server/config/override.js +++ /dev/null @@ -1,28 +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 _ from 'lodash'; -import explodeBy from './explode_by'; -import { getFlattenedObject } from '../../../core/utils'; - -export default function(target, source) { - const _target = getFlattenedObject(target); - const _source = getFlattenedObject(source); - return explodeBy('.', _.defaults(_source, _target)); -} diff --git a/src/legacy/server/config/override.test.js b/src/legacy/server/config/override.test.js deleted file mode 100644 index 331c586e28a87..0000000000000 --- a/src/legacy/server/config/override.test.js +++ /dev/null @@ -1,44 +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 override from './override'; - -describe('override(target, source)', function() { - it('should override the values form source to target', function() { - const target = { - test: { - enable: true, - host: ['host-01', 'host-02'], - client: { - type: 'sql', - }, - }, - }; - const source = { test: { client: { type: 'nosql' } } }; - expect(override(target, source)).toEqual({ - test: { - enable: true, - host: ['host-01', 'host-02'], - client: { - type: 'nosql', - }, - }, - }); - }); -}); diff --git a/src/legacy/server/config/override.test.ts b/src/legacy/server/config/override.test.ts new file mode 100644 index 0000000000000..4e21a88e79e61 --- /dev/null +++ b/src/legacy/server/config/override.test.ts @@ -0,0 +1,130 @@ +/* + * 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 { override } from './override'; + +describe('override(target, source)', function() { + it('should override the values form source to target', function() { + const target = { + test: { + enable: true, + host: ['something else'], + client: { + type: 'sql', + }, + }, + }; + + const source = { + test: { + host: ['host-01', 'host-02'], + client: { + type: 'nosql', + }, + foo: { + bar: { + baz: 1, + }, + }, + }, + }; + + expect(override(target, source)).toMatchInlineSnapshot(` + Object { + "test": Object { + "client": Object { + "type": "nosql", + }, + "enable": true, + "foo": Object { + "bar": Object { + "baz": 1, + }, + }, + "host": Array [ + "host-01", + "host-02", + ], + }, + } + `); + }); + + it('does not mutate arguments', () => { + const target = { + foo: { + bar: 1, + baz: 1, + }, + }; + + const source = { + foo: { + bar: 2, + }, + box: 2, + }; + + expect(override(target, source)).toMatchInlineSnapshot(` + Object { + "box": 2, + "foo": Object { + "bar": 2, + "baz": 1, + }, + } + `); + expect(target).not.toHaveProperty('box'); + expect(source.foo).not.toHaveProperty('baz'); + }); + + it('explodes keys with dots in them', () => { + const target = { + foo: { + bar: 1, + }, + 'baz.box.boot.bar.bar': 20, + }; + + const source = { + 'foo.bar': 2, + 'baz.box.boot': { + 'bar.foo': 10, + }, + }; + + expect(override(target, source)).toMatchInlineSnapshot(` + Object { + "baz": Object { + "box": Object { + "boot": Object { + "bar": Object { + "bar": 20, + "foo": 10, + }, + }, + }, + }, + "foo": Object { + "bar": 2, + }, + } + `); + }); +}); diff --git a/src/legacy/server/config/override.ts b/src/legacy/server/config/override.ts new file mode 100644 index 0000000000000..3dd7d62016004 --- /dev/null +++ b/src/legacy/server/config/override.ts @@ -0,0 +1,52 @@ +/* + * 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. + */ + +const isObject = (v: any): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +const assignDeep = (target: Record, source: Record) => { + for (let [key, value] of Object.entries(source)) { + // unwrap dot-separated keys + if (key.includes('.')) { + const [first, ...others] = key.split('.'); + key = first; + value = { [others.join('.')]: value }; + } + + if (isObject(value)) { + if (!target.hasOwnProperty(key)) { + target[key] = {}; + } + + assignDeep(target[key], value); + } else { + target[key] = value; + } + } +}; + +export const override = (...sources: Array>): Record => { + const result = {}; + + for (const object of sources) { + assignDeep(result, object); + } + + return result; +}; diff --git a/src/legacy/ui/public/field_editor/field_editor.test.tsx b/src/legacy/ui/public/field_editor/field_editor.test.tsx index 5716305b51483..ced7aa27e5065 100644 --- a/src/legacy/ui/public/field_editor/field_editor.test.tsx +++ b/src/legacy/ui/public/field_editor/field_editor.test.tsx @@ -22,9 +22,9 @@ import React from 'react'; import { npStart } from 'ui/new_platform'; import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; import { - Field, IndexPattern, - IndexPatternFieldList, + IndexPatternField, + IIndexPatternFieldList, FieldFormatInstanceType, } from 'src/plugins/data/public'; import { HttpStart } from '../../../../core/public'; @@ -76,10 +76,10 @@ jest.mock('./components/field_format_editor', () => ({ FieldFormatEditor: 'field-format-editor', })); -const fields: Field[] = [ +const fields: IndexPatternField[] = [ { name: 'foobar', - } as Field, + } as IndexPatternField, ]; // @ts-ignore @@ -114,7 +114,7 @@ describe('FieldEditor', () => { beforeEach(() => { indexPattern = ({ - fields: fields as IndexPatternFieldList, + fields: fields as IIndexPatternFieldList, } as unknown) as IndexPattern; npStart.plugins.data.fieldFormats.getDefaultType = jest.fn( @@ -133,7 +133,7 @@ describe('FieldEditor', () => { const component = shallowWithI18nProvider( ); @@ -149,18 +149,18 @@ describe('FieldEditor', () => { name: 'test', script: 'doc.test.value', }; - indexPattern.fields.push(testField as Field); + indexPattern.fields.push(testField as IndexPatternField); indexPattern.fields.getByName = name => { const flds = { [testField.name]: testField, }; - return flds[name] as Field; + return flds[name] as IndexPatternField; }; const component = shallowWithI18nProvider( ); @@ -177,18 +177,18 @@ describe('FieldEditor', () => { script: 'doc.test.value', lang: 'testlang', }; - indexPattern.fields.push((testField as unknown) as Field); + indexPattern.fields.push((testField as unknown) as IndexPatternField); indexPattern.fields.getByName = name => { const flds = { [testField.name]: testField, }; - return flds[name] as Field; + return flds[name] as IndexPatternField; }; const component = shallowWithI18nProvider( ); @@ -203,7 +203,7 @@ describe('FieldEditor', () => { const component = shallowWithI18nProvider( ); @@ -226,7 +226,7 @@ describe('FieldEditor', () => { const component = shallowWithI18nProvider( ); diff --git a/src/legacy/ui/public/field_editor/field_editor.tsx b/src/legacy/ui/public/field_editor/field_editor.tsx index aa62a53f2c32a..7de70f5d956e8 100644 --- a/src/legacy/ui/public/field_editor/field_editor.tsx +++ b/src/legacy/ui/public/field_editor/field_editor.tsx @@ -55,13 +55,13 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { + IndexPatternField, + FieldFormatInstanceType, IndexPattern, IFieldType, KBN_FIELD_TYPES, ES_FIELD_TYPES, } from '../../../../plugins/data/public'; -import { FieldFormatInstanceType } from '../../../../plugins/data/common'; -import { Field } from '../../../../plugins/data/public'; import { ScriptingDisabledCallOut, ScriptingWarningCallOut, @@ -114,7 +114,7 @@ interface InitialFieldTypeFormat extends FieldTypeFormat { defaultFieldFormat: FieldFormatInstanceType; } -interface FieldClone extends Field { +interface FieldClone extends IndexPatternField { format: any; } @@ -139,7 +139,7 @@ export interface FieldEditorState { export interface FieldEdiorProps { indexPattern: IndexPattern; - field: Field; + field: IndexPatternField; helpers: { getConfig: (key: string) => any; getHttpStart: () => HttpStart; diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index 271586bb8c582..3caba24748bfa 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -462,16 +462,6 @@ export const npStart = { types: aggTypesRegistry.start(), }, __LEGACY: { - AggConfig: sinon.fake(), - AggType: sinon.fake(), - aggTypeFieldFilters: { - addFilter: sinon.fake(), - filter: sinon.fake(), - }, - FieldParamType: sinon.fake(), - MetricAggType: sinon.fake(), - parentPipelineAggHelper: sinon.fake(), - siblingPipelineAggHelper: sinon.fake(), esClient: { search: sinon.fake(), msearch: sinon.fake(), diff --git a/src/legacy/ui/public/styles/_legacy/components/_table.scss b/src/legacy/ui/public/styles/_legacy/components/_table.scss index c9472cbd2faa7..d0ac9d6f79862 100644 --- a/src/legacy/ui/public/styles/_legacy/components/_table.scss +++ b/src/legacy/ui/public/styles/_legacy/components/_table.scss @@ -1,4 +1,4 @@ -@import '../../../../../core_plugins/kibana/public/discover/np_ready/mixins'; +@import '../../../../../../plugins/discover/public/application/mixins'; .table { // Nesting diff --git a/src/optimize/bundles_route/dynamic_asset_response.ts b/src/optimize/bundles_route/dynamic_asset_response.ts index a020c6935eeec..2f5395341abb1 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/console/server/lib/spec_definitions/js/aggregations.ts b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts index 1170c9edd2366..ce3155919bd1d 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts +++ b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts @@ -148,7 +148,7 @@ const rules = { shard_size: 10, order: { __template: { - _term: 'asc', + _key: 'asc', }, _term: { __one_of: ['asc', 'desc'] }, _count: { __one_of: ['asc', 'desc'] }, 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 4d15e7e899fa8..ff4e50ba8c327 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 ddc255295e89b..5526af2f83850 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 5dab21ff671b4..40231de7597f1 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 7de054f2eaa9c..b28822120b31e 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 bd20c6f632a3a..ebaac6b745bec 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 60d8079b22347..d4433f3825fea 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, }; @@ -252,12 +254,12 @@ export const indexPatterns = { export { IndexPatternsContract, IndexPattern, + IIndexPatternFieldList, Field as IndexPatternField, TypeMeta as IndexPatternTypeMeta, AggregationRestrictions as IndexPatternAggRestrictions, // TODO: exported only in stub_index_pattern test. Move into data plugin and remove export. - FieldList as IndexPatternFieldList, - Field, + getIndexPatternFieldListCreator, } from './index_patterns'; export { @@ -288,13 +290,8 @@ export { import { // aggs - AggConfigs, - aggTypeFilters, - aggGroupNamesMap, CidrMask, - convertDateRangeToString, - convertIPRangeToString, - intervalOptions, // only used in Discover + intervalOptions, isDateHistogramBucketAggConfig, isNumberType, isStringType, @@ -326,26 +323,22 @@ export { ParsedInterval } from '../common'; export { // aggs + AggGroupLabels, + AggGroupName, AggGroupNames, - AggParam, // only the type is used externally, only in vis editor - AggParamOption, // only the type is used externally + AggParam, + AggParamOption, AggParamType, - AggTypeFieldFilters, // TODO convert to interface - AggTypeFilters, // TODO convert to interface AggConfigOptions, BUCKET_TYPES, - DateRangeKey, // only used in field formatter deserialization, which will live in data IAggConfig, IAggConfigs, - IAggGroupNames, IAggType, IFieldParamType, IMetricAggType, - IpRangeKey, // only used in field formatter deserialization, which will live in data METRIC_TYPES, - OptionedParamEditorProps, // only type is used externally OptionedParamType, - OptionedValueProp, // only type is used externally + OptionedValueProp, // search ES_SEARCH_STRATEGY, SYNC_SEARCH_STRATEGY, @@ -383,17 +376,12 @@ export { // Search namespace export const search = { aggs: { - AggConfigs, - aggGroupNamesMap, - aggTypeFilters, CidrMask, - convertDateRangeToString, - convertIPRangeToString, dateHistogramInterval, - intervalOptions, // only used in Discover + intervalOptions, InvalidEsCalendarIntervalError, InvalidEsIntervalFormatError, - isDateHistogramBucketAggConfig, + isDateHistogramBucketAggConfig, // TODO: remove in build_pipeline refactor isNumberType, isStringType, isType, diff --git a/src/plugins/data/public/index_patterns/fields/field.ts b/src/plugins/data/public/index_patterns/fields/field.ts index d83c0a7d3445e..12db09bbb846f 100644 --- a/src/plugins/data/public/index_patterns/fields/field.ts +++ b/src/plugins/data/public/index_patterns/fields/field.ts @@ -18,15 +18,26 @@ */ import { i18n } from '@kbn/i18n'; +import { ToastsStart } from 'kibana/public'; // @ts-ignore import { ObjDefine } from './obj_define'; import { IndexPattern } from '../index_patterns'; -import { getNotifications, getFieldFormats } from '../../services'; -import { IFieldType, getKbnFieldType, IFieldSubType, FieldFormat } from '../../../common'; -import { shortenDottedString } from '../../../common/utils'; +import { + IFieldType, + getKbnFieldType, + IFieldSubType, + FieldFormat, + shortenDottedString, +} from '../../../common'; +import { FieldFormatsStart } from '../../field_formats'; export type FieldSpec = Record; +interface FieldDependencies { + fieldFormats: FieldFormatsStart; + toastNotifications: ToastsStart; +} + export class Field implements IFieldType { name: string; type: string; @@ -52,7 +63,8 @@ export class Field implements IFieldType { constructor( indexPattern: IndexPattern, spec: FieldSpec | Field, - shortDotsEnable: boolean = false + shortDotsEnable: boolean, + { fieldFormats, toastNotifications }: FieldDependencies ) { // unwrap old instances of Field if (spec instanceof Field) spec = spec.$$spec; @@ -77,9 +89,8 @@ export class Field implements IFieldType { values: { name: spec.name, title: indexPattern.title }, defaultMessage: 'Field {name} in indexPattern {title} is using an unknown field type.', }); - const { toasts } = getNotifications(); - toasts.addDanger({ + toastNotifications.addDanger({ title, text, }); @@ -90,11 +101,9 @@ export class Field implements IFieldType { let format = spec.format; if (!FieldFormat.isInstanceOfFieldFormat(format)) { - const fieldFormatsService = getFieldFormats(); - format = indexPattern.fieldFormatMap[spec.name] || - fieldFormatsService.getDefaultInstance(spec.type, spec.esTypes); + fieldFormats.getDefaultInstance(spec.type, spec.esTypes); } const indexed = !!spec.indexed; diff --git a/src/plugins/data/public/index_patterns/fields/field_list.ts b/src/plugins/data/public/index_patterns/fields/field_list.ts index 9772370199b24..0631e00a1fb62 100644 --- a/src/plugins/data/public/index_patterns/fields/field_list.ts +++ b/src/plugins/data/public/index_patterns/fields/field_list.ts @@ -18,13 +18,20 @@ */ import { findIndex } from 'lodash'; +import { ToastsStart } from 'kibana/public'; import { IndexPattern } from '../index_patterns'; import { IFieldType } from '../../../common'; import { Field, FieldSpec } from './field'; +import { FieldFormatsStart } from '../../field_formats'; type FieldMap = Map; -export interface IFieldList extends Array { +interface FieldListDependencies { + fieldFormats: FieldFormatsStart; + toastNotifications: ToastsStart; +} + +export interface IIndexPatternFieldList extends Array { getByName(name: Field['name']): Field | undefined; getByType(type: Field['type']): Field[]; add(field: FieldSpec): void; @@ -32,51 +39,70 @@ export interface IFieldList extends Array { update(field: FieldSpec): void; } -export class FieldList extends Array implements IFieldList { - private byName: FieldMap = new Map(); - private groups: Map = new Map(); - private indexPattern: IndexPattern; - private shortDotsEnable: boolean; - private setByName = (field: Field) => this.byName.set(field.name, field); - private setByGroup = (field: Field) => { - if (typeof this.groups.get(field.type) === 'undefined') { - this.groups.set(field.type, new Map()); - } - this.groups.get(field.type)!.set(field.name, field); - }; - private removeByGroup = (field: IFieldType) => this.groups.get(field.type)!.delete(field.name); +export type CreateIndexPatternFieldList = ( + indexPattern: IndexPattern, + specs?: FieldSpec[], + shortDotsEnable?: boolean +) => IIndexPatternFieldList; - constructor(indexPattern: IndexPattern, specs: FieldSpec[] = [], shortDotsEnable = false) { - super(); - this.indexPattern = indexPattern; - this.shortDotsEnable = shortDotsEnable; +export const getIndexPatternFieldListCreator = ({ + fieldFormats, + toastNotifications, +}: FieldListDependencies): CreateIndexPatternFieldList => (...fieldListParams) => { + class FieldList extends Array implements IIndexPatternFieldList { + private byName: FieldMap = new Map(); + private groups: Map = new Map(); + private indexPattern: IndexPattern; + private shortDotsEnable: boolean; + private setByName = (field: Field) => this.byName.set(field.name, field); + private setByGroup = (field: Field) => { + if (typeof this.groups.get(field.type) === 'undefined') { + this.groups.set(field.type, new Map()); + } + this.groups.get(field.type)!.set(field.name, field); + }; + private removeByGroup = (field: IFieldType) => this.groups.get(field.type)!.delete(field.name); - specs.map(field => this.add(field)); - } + constructor(indexPattern: IndexPattern, specs: FieldSpec[] = [], shortDotsEnable = false) { + super(); + this.indexPattern = indexPattern; + this.shortDotsEnable = shortDotsEnable; - getByName = (name: Field['name']) => this.byName.get(name); - getByType = (type: Field['type']) => [...(this.groups.get(type) || new Map()).values()]; - add = (field: FieldSpec) => { - const newField = new Field(this.indexPattern, field, this.shortDotsEnable); - this.push(newField); - this.setByName(newField); - this.setByGroup(newField); - }; + specs.map(field => this.add(field)); + } - remove = (field: IFieldType) => { - this.removeByGroup(field); - this.byName.delete(field.name); + getByName = (name: Field['name']) => this.byName.get(name); + getByType = (type: Field['type']) => [...(this.groups.get(type) || new Map()).values()]; + add = (field: FieldSpec) => { + const newField = new Field(this.indexPattern, field, this.shortDotsEnable, { + fieldFormats, + toastNotifications, + }); + this.push(newField); + this.setByName(newField); + this.setByGroup(newField); + }; - const fieldIndex = findIndex(this, { name: field.name }); - this.splice(fieldIndex, 1); - }; + remove = (field: IFieldType) => { + this.removeByGroup(field); + this.byName.delete(field.name); - update = (field: FieldSpec) => { - const newField = new Field(this.indexPattern, field, this.shortDotsEnable); - const index = this.findIndex(f => f.name === newField.name); - this.splice(index, 1, newField); - this.setByName(newField); - this.removeByGroup(newField); - this.setByGroup(newField); - }; -} + const fieldIndex = findIndex(this, { name: field.name }); + this.splice(fieldIndex, 1); + }; + + update = (field: FieldSpec) => { + const newField = new Field(this.indexPattern, field, this.shortDotsEnable, { + fieldFormats, + toastNotifications, + }); + const index = this.findIndex(f => f.name === newField.name); + this.splice(index, 1, newField); + this.setByName(newField); + this.removeByGroup(newField); + this.setByGroup(newField); + }; + } + + return new FieldList(...fieldListParams); +}; diff --git a/src/plugins/data/public/index_patterns/index.ts b/src/plugins/data/public/index_patterns/index.ts index dcf799184b01c..e05db0e4d4cec 100644 --- a/src/plugins/data/public/index_patterns/index.ts +++ b/src/plugins/data/public/index_patterns/index.ts @@ -29,7 +29,7 @@ export { export { getRoutes } from './utils'; export { flattenHitWrapper, formatHitProvider } from './index_patterns'; -export { Field, FieldList } from './fields'; +export { getIndexPatternFieldListCreator, Field, IIndexPatternFieldList } from './fields'; // TODO: figure out how to replace IndexPatterns in get_inner_angular. export { diff --git a/src/plugins/data/public/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/public/index_patterns/index_patterns/index_pattern.ts index 768029136879d..f39be78433710 100644 --- a/src/plugins/data/public/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/public/index_patterns/index_patterns/index_pattern.ts @@ -32,7 +32,7 @@ import { ES_FIELD_TYPES, KBN_FIELD_TYPES, IIndexPattern, IFieldType } from '../. import { findByTitle, getRoutes } from '../utils'; import { IndexPatternMissingIndices } from '../lib'; -import { Field, FieldList, IFieldList } from '../fields'; +import { Field, IIndexPatternFieldList, getIndexPatternFieldListCreator } from '../fields'; import { createFieldsFetcher } from './_fields_fetcher'; import { formatHitProvider } from './format_hit'; import { flattenHitWrapper } from './flatten_hit'; @@ -51,7 +51,7 @@ export class IndexPattern implements IIndexPattern { public type?: string; public fieldFormatMap: any; public typeMeta?: TypeMeta; - public fields: IFieldList; + public fields: IIndexPatternFieldList; public timeFieldName: string | undefined; public formatHit: any; public formatField: any; @@ -106,7 +106,12 @@ export class IndexPattern implements IIndexPattern { this.shortDotsEnable = this.getConfig('shortDots:enable'); this.metaFields = this.getConfig('metaFields'); - this.fields = new FieldList(this, [], this.shortDotsEnable); + this.createFieldList = getIndexPatternFieldListCreator({ + fieldFormats: getFieldFormats(), + toastNotifications: getNotifications().toasts, + }); + + this.fields = this.createFieldList(this, [], this.shortDotsEnable); this.fieldsFetcher = createFieldsFetcher(this, apiClient, this.getConfig('metaFields')); this.flattenHit = flattenHitWrapper(this, this.getConfig('metaFields')); this.formatHit = formatHitProvider( @@ -131,7 +136,7 @@ export class IndexPattern implements IIndexPattern { private initFields(input?: any) { const newValue = input || this.fields; - this.fields = new FieldList(this, newValue, this.shortDotsEnable); + this.fields = this.createFieldList(this, newValue, this.shortDotsEnable); } private isFieldRefreshRequired(): boolean { @@ -281,7 +286,11 @@ export class IndexPattern implements IIndexPattern { filterable: true, searchable: true, }, - false + false, + { + fieldFormats: getFieldFormats(), + toastNotifications: getNotifications().toasts, + } ) ); diff --git a/src/plugins/data/public/index_patterns/index_patterns/index_patterns.test.ts b/src/plugins/data/public/index_patterns/index_patterns/index_patterns.test.ts index cf1f83d0e28cb..fc0be270e9c50 100644 --- a/src/plugins/data/public/index_patterns/index_patterns/index_patterns.test.ts +++ b/src/plugins/data/public/index_patterns/index_patterns/index_patterns.test.ts @@ -19,12 +19,13 @@ // eslint-disable-next-line max-classes-per-file import { IndexPatternsService } from './index_patterns'; -import { - SavedObjectsClientContract, - HttpSetup, - SavedObjectsFindResponsePublic, - CoreStart, -} from 'kibana/public'; +import { SavedObjectsClientContract, SavedObjectsFindResponsePublic } from 'kibana/public'; +import { coreMock, httpServiceMock } from '../../../../../core/public/mocks'; +import { fieldFormatsServiceMock } from '../../field_formats/mocks'; + +const core = coreMock.createStart(); +const http = httpServiceMock.createStartContract(); +const fieldFormats = fieldFormatsServiceMock.createStartContract(); jest.mock('./index_pattern', () => { class IndexPattern { @@ -61,10 +62,7 @@ describe('IndexPatterns', () => { }) as Promise> ); - const core = {} as CoreStart; - const http = {} as HttpSetup; - - indexPatterns = new IndexPatternsService(core, savedObjectsClient, http); + indexPatterns = new IndexPatternsService(core, savedObjectsClient, http, fieldFormats); }); test('does cache gets for the same id', async () => { 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 b5d66a6aab60a..515f2e7cf95ee 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 @@ -32,28 +32,60 @@ import { createEnsureDefaultIndexPattern, EnsureDefaultIndexPattern, } from './ensure_default_index_pattern'; +import { + getIndexPatternFieldListCreator, + CreateIndexPatternFieldList, + Field, + FieldSpec, +} from '../fields'; +import { FieldFormatsStart } from '../../field_formats'; 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; - - constructor(core: CoreStart, savedObjectsClient: SavedObjectsClientContract, http: HttpStart) { + createFieldList: CreateIndexPatternFieldList; + createField: ( + indexPattern: IndexPattern, + spec: FieldSpec | Field, + shortDotsEnable: boolean + ) => Field; + + constructor( + core: CoreStart, + savedObjectsClient: SavedObjectsClientContract, + http: HttpStart, + fieldFormats: FieldFormatsStart + ) { this.apiClient = new IndexPatternsApiClient(http); this.config = core.uiSettings; this.savedObjectsClient = savedObjectsClient; this.ensureDefaultIndexPattern = createEnsureDefaultIndexPattern(core); + this.createFieldList = getIndexPatternFieldListCreator({ + fieldFormats, + toastNotifications: core.notifications.toasts, + }); + this.createField = (indexPattern, spec, shortDotsEnable) => { + return new Field(indexPattern, spec, shortDotsEnable, { + fieldFormats, + toastNotifications: core.notifications.toasts, + }); + }; } 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/mocks.ts b/src/plugins/data/public/mocks.ts index ba1df89c41358..7307c93139d59 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -57,6 +57,8 @@ const createStartContract = (): Start => { SearchBar: jest.fn(), }, indexPatterns: ({ + createField: jest.fn(() => {}), + createFieldList: jest.fn(() => []), ensureDefaultIndexPattern: jest.fn(), make: () => ({ fieldsFetcher: { diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index f3a88287313a0..08796216db1e0 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -126,12 +126,12 @@ export class DataPublicPlugin implements Plugin; +// Warning: (ae-missing-release-tag) "AggGroupLabels" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const AggGroupLabels: { + [AggGroupNames.Buckets]: string; + [AggGroupNames.Metrics]: string; + [AggGroupNames.None]: string; +}; + +// Warning: (ae-missing-release-tag) "AggGroupName" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type AggGroupName = $Values; + // Warning: (ae-missing-release-tag) "AggGroupNames" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -108,32 +122,6 @@ export class AggParamType extends Ba makeAgg: (agg: TAggConfig, state?: AggConfigSerialized) => TAggConfig; } -// Warning: (ae-missing-release-tag) "AggTypeFieldFilters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AggTypeFieldFilter" -// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AggType" -// -// @public -export class AggTypeFieldFilters { - // Warning: (ae-forgotten-export) The symbol "AggTypeFieldFilter" needs to be exported by the entry point index.d.ts - // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AggTypeFieldFilter" - addFilter(filter: AggTypeFieldFilter): void; - // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "any" - filter(fields: IndexPatternField_2[], aggConfig: IAggConfig): IndexPatternField_2[]; - } - -// Warning: (ae-missing-release-tag) "AggTypeFilters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AggTypeFilter" -// Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AggConfig" -// -// @public -export class AggTypeFilters { - // Warning: (ae-forgotten-export) The symbol "AggTypeFilter" needs to be exported by the entry point index.d.ts - // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AggTypeFilter" - addFilter(filter: AggTypeFilter): void; - // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "AggType" - filter(aggTypes: IAggType[], indexPattern: IndexPattern, aggConfig: IAggConfig, aggFilter: string[]): IAggType[]; - } - // Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -266,16 +254,6 @@ export interface DataPublicPluginStart { }; } -// Warning: (ae-missing-release-tag) "DateRangeKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface DateRangeKey { - // (undocumented) - from: number; - // (undocumented) - to: number; -} - // @public (undocumented) export enum ES_FIELD_TYPES { // (undocumented) @@ -384,6 +362,7 @@ export const 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; }; @@ -451,55 +430,6 @@ export interface FetchOptions { searchStrategyId?: string; } -// Warning: (ae-missing-release-tag) "Field" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -class Field implements IFieldType { - // Warning: (ae-forgotten-export) The symbol "FieldSpec" needs to be exported by the entry point index.d.ts - // - // (undocumented) - $$spec: FieldSpec; - constructor(indexPattern: IndexPattern, spec: FieldSpec | Field, shortDotsEnable?: boolean); - // (undocumented) - aggregatable?: boolean; - // (undocumented) - conflictDescriptions?: Record; - // (undocumented) - count?: number; - // (undocumented) - displayName?: string; - // (undocumented) - esTypes?: string[]; - // (undocumented) - filterable?: boolean; - // (undocumented) - format: any; - // (undocumented) - indexPattern?: IndexPattern; - // (undocumented) - lang?: string; - // (undocumented) - name: string; - // (undocumented) - script?: string; - // (undocumented) - scripted?: boolean; - // (undocumented) - searchable?: boolean; - // (undocumented) - sortable?: boolean; - // (undocumented) - subType?: IFieldSubType; - // (undocumented) - type: string; - // (undocumented) - visualizable?: boolean; -} - -export { Field } - -export { Field as IndexPatternField } - // Warning: (ae-missing-release-tag) "FieldFormat" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -680,6 +610,13 @@ export function getDefaultQuery(language?: QueryLanguage): { // @public (undocumented) export function getEsPreference(uiSettings: IUiSettingsClient_2, sessionId?: string): any; +// Warning: (ae-forgotten-export) The symbol "FieldListDependencies" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "CreateIndexPatternFieldList" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "getIndexPatternFieldListCreator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const getIndexPatternFieldListCreator: ({ fieldFormats, toastNotifications, }: FieldListDependencies) => CreateIndexPatternFieldList; + // Warning: (ae-missing-release-tag) "getKbnTypeNames" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -714,11 +651,6 @@ export type IAggConfig = AggConfig; // @internal export type IAggConfigs = AggConfigs; -// Warning: (ae-missing-release-tag) "IAggGroupNames" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type IAggGroupNames = $Values; - // Warning: (ae-forgotten-export) The symbol "AggType" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "IAggType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -856,6 +788,24 @@ export interface IIndexPattern { type?: string; } +// Warning: (ae-missing-release-tag) "IIndexPatternFieldList" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface IIndexPatternFieldList extends Array { + // Warning: (ae-forgotten-export) The symbol "FieldSpec" needs to be exported by the entry point index.d.ts + // + // (undocumented) + add(field: FieldSpec): void; + // (undocumented) + getByName(name: IndexPatternField['name']): IndexPatternField | undefined; + // (undocumented) + getByType(type: IndexPatternField['type']): IndexPatternField[]; + // (undocumented) + remove(field: IFieldType): void; + // (undocumented) + update(field: FieldSpec): void; +} + // Warning: (ae-missing-release-tag) "IKibanaSearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -897,10 +847,8 @@ export class IndexPattern implements IIndexPattern { _fetchFields(): Promise; // (undocumented) fieldFormatMap: any; - // Warning: (ae-forgotten-export) The symbol "IFieldList" needs to be exported by the entry point index.d.ts - // // (undocumented) - fields: IFieldList; + fields: IIndexPatternFieldList; // (undocumented) fieldsFetcher: any; // (undocumented) @@ -928,17 +876,17 @@ export class IndexPattern implements IIndexPattern { }[]; }; // (undocumented) - getFieldByName(name: string): Field | void; + getFieldByName(name: string): IndexPatternField | void; // (undocumented) - getNonScriptedFields(): Field[]; + getNonScriptedFields(): IndexPatternField[]; // (undocumented) - getScriptedFields(): Field[]; + getScriptedFields(): IndexPatternField[]; // (undocumented) getSourceFiltering(): { excludes: any[]; }; // (undocumented) - getTimeField(): Field | undefined; + getTimeField(): IndexPatternField | undefined; // (undocumented) id?: string; // (undocumented) @@ -1015,21 +963,48 @@ export interface IndexPatternAttributes { typeMeta: string; } -// Warning: (ae-missing-release-tag) "FieldList" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// Warning: (ae-missing-release-tag) "Field" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export class IndexPatternFieldList extends Array implements IFieldList { - constructor(indexPattern: IndexPattern, specs?: FieldSpec[], shortDotsEnable?: boolean); +export class IndexPatternField implements IFieldType { + // (undocumented) + $$spec: FieldSpec; + // Warning: (ae-forgotten-export) The symbol "FieldDependencies" needs to be exported by the entry point index.d.ts + constructor(indexPattern: IndexPattern, spec: FieldSpec | IndexPatternField, shortDotsEnable: boolean, { fieldFormats, toastNotifications }: FieldDependencies); + // (undocumented) + aggregatable?: boolean; + // (undocumented) + conflictDescriptions?: Record; + // (undocumented) + count?: number; + // (undocumented) + displayName?: string; + // (undocumented) + esTypes?: string[]; // (undocumented) - add: (field: Record) => void; + filterable?: boolean; + // (undocumented) + format: any; + // (undocumented) + indexPattern?: IndexPattern; + // (undocumented) + lang?: string; + // (undocumented) + name: string; + // (undocumented) + script?: string; + // (undocumented) + scripted?: boolean; + // (undocumented) + searchable?: boolean; // (undocumented) - getByName: (name: string) => Field | undefined; + sortable?: boolean; // (undocumented) - getByType: (type: string) => any[]; + subType?: IFieldSubType; // (undocumented) - remove: (field: IFieldType) => void; + type: string; // (undocumented) - update: (field: Record) => void; + visualizable?: boolean; } // Warning: (ae-missing-release-tag) "indexPatterns" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1101,18 +1076,6 @@ export type InputTimeRange = TimeRange | { to: Moment; }; -// Warning: (ae-missing-release-tag) "IpRangeKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type IpRangeKey = { - type: 'mask'; - mask: string; -} | { - type: 'range'; - from: string; - to: string; -}; - // Warning: (ae-missing-release-tag) "IRequestTypesMap" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1290,16 +1253,6 @@ export enum METRIC_TYPES { TOP_HITS = "top_hits" } -// Warning: (ae-missing-release-tag) "OptionedParamEditorProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export interface OptionedParamEditorProps { - // (undocumented) - aggParam: { - options: T[]; - }; -} - // Warning: (ae-missing-release-tag) "OptionedParamType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1401,7 +1354,7 @@ export interface QueryState { // Warning: (ae-missing-release-tag) "QueryStringInput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const QueryStringInput: React.FC>; +export const QueryStringInput: React.FC>; // @public (undocumented) export type QuerySuggestion = QuerySuggestionBasic | QuerySuggestionField; @@ -1578,12 +1531,7 @@ export type SavedQueryTimeFilter = TimeRange & { // @public (undocumented) export const search: { aggs: { - AggConfigs: typeof AggConfigs; - aggGroupNamesMap: () => Record<"metrics" | "buckets", string>; - aggTypeFilters: import("./search/aggs/filter/agg_type_filters").AggTypeFilters; CidrMask: typeof CidrMask; - convertDateRangeToString: typeof convertDateRangeToString; - convertIPRangeToString: (range: import("./search").IpRangeKey, format: (val: any) => string) => string; dateHistogramInterval: typeof dateHistogramInterval; intervalOptions: ({ display: string; @@ -1618,8 +1566,8 @@ export const search: { // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "indexPatterns" | "refreshInterval" | "screenTitle" | "dataTestSubj" | "customSubmitButton" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "onRefresh" | "timeHistory" | "onFiltersUpdated" | "onRefreshChange">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "indexPatterns" | "refreshInterval" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "onRefresh" | "timeHistory" | "onFiltersUpdated" | "onRefreshChange">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts @@ -1838,53 +1786,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:384:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:384:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:384:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:384:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:415: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 034af03842ab8..a5885a59f60ed 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 8da83580ef5d6..cbbf2f2754312 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/data/public/search/aggs/agg_config.ts b/src/plugins/data/public/search/aggs/agg_config.ts index 973c69e3d4f5f..86a2c3e0e82e4 100644 --- a/src/plugins/data/public/search/aggs/agg_config.ts +++ b/src/plugins/data/public/search/aggs/agg_config.ts @@ -19,7 +19,7 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Assign } from '@kbn/utility-types'; +import { Assign, Ensure } from '@kbn/utility-types'; import { ExpressionAstFunction, ExpressionAstArgument } from 'src/plugins/expressions/public'; import { IAggType } from './agg_type'; import { writeParams } from './agg_params'; @@ -31,17 +31,22 @@ import { FieldFormatsStart } from '../../field_formats'; type State = string | number | boolean | null | undefined | SerializableState; -interface SerializableState { +/** @internal **/ +export interface SerializableState { [key: string]: State | State[]; } -export interface AggConfigSerialized { - type: string; - enabled?: boolean; - id?: string; - params?: SerializableState; - schema?: string; -} +/** @internal **/ +export type AggConfigSerialized = Ensure< + { + type: string; + enabled?: boolean; + id?: string; + params?: SerializableState; + schema?: string; + }, + SerializableState +>; export interface AggConfigDependencies { fieldFormats: FieldFormatsStart; diff --git a/src/plugins/data/public/search/aggs/agg_groups.ts b/src/plugins/data/public/search/aggs/agg_groups.ts index 9cebff76c9684..dec3397126e67 100644 --- a/src/plugins/data/public/search/aggs/agg_groups.ts +++ b/src/plugins/data/public/search/aggs/agg_groups.ts @@ -25,15 +25,17 @@ export const AggGroupNames = Object.freeze({ Metrics: 'metrics' as 'metrics', None: 'none' as 'none', }); -export type IAggGroupNames = $Values; -type IAggGroupNamesMap = () => Record<'buckets' | 'metrics', string>; +export type AggGroupName = $Values; -export const aggGroupNamesMap: IAggGroupNamesMap = () => ({ +export const AggGroupLabels = { + [AggGroupNames.Buckets]: i18n.translate('data.search.aggs.aggGroups.bucketsText', { + defaultMessage: 'Buckets', + }), [AggGroupNames.Metrics]: i18n.translate('data.search.aggs.aggGroups.metricsText', { defaultMessage: 'Metrics', }), - [AggGroupNames.Buckets]: i18n.translate('data.search.aggs.aggGroups.bucketsText', { - defaultMessage: 'Buckets', + [AggGroupNames.None]: i18n.translate('data.search.aggs.aggGroups.noneText', { + defaultMessage: 'None', }), -}); +}; diff --git a/src/plugins/data/public/search/aggs/agg_types.ts b/src/plugins/data/public/search/aggs/agg_types.ts index da07f581c9274..7c7d7609cc82f 100644 --- a/src/plugins/data/public/search/aggs/agg_types.ts +++ b/src/plugins/data/public/search/aggs/agg_types.ts @@ -105,6 +105,29 @@ export const getAggTypes = ({ ], }); +/** Buckets: **/ +import { aggFilter } from './buckets/filter_fn'; +import { aggFilters } from './buckets/filters_fn'; +import { aggSignificantTerms } from './buckets/significant_terms_fn'; +import { aggIpRange } from './buckets/ip_range_fn'; +import { aggDateRange } from './buckets/date_range_fn'; +import { aggRange } from './buckets/range_fn'; +import { aggGeoTile } from './buckets/geo_tile_fn'; +import { aggGeoHash } from './buckets/geo_hash_fn'; +import { aggHistogram } from './buckets/histogram_fn'; +import { aggDateHistogram } from './buckets/date_histogram_fn'; import { aggTerms } from './buckets/terms_fn'; -export const getAggTypesFunctions = () => [aggTerms]; +export const getAggTypesFunctions = () => [ + aggFilter, + aggFilters, + aggSignificantTerms, + aggIpRange, + aggDateRange, + aggRange, + aggGeoTile, + aggGeoHash, + aggDateHistogram, + aggHistogram, + aggTerms, +]; diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts index 3ecdc17cb57f3..219bb5440c8da 100644 --- a/src/plugins/data/public/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/public/search/aggs/buckets/date_histogram.ts @@ -27,7 +27,7 @@ import { BucketAggType, IBucketAggConfig } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { createFilterDateHistogram } from './create_filter/date_histogram'; import { intervalOptions } from './_interval_options'; -import { dateHistogramInterval } from '../../../../common'; +import { dateHistogramInterval, TimeRange } from '../../../../common'; import { writeParams } from '../agg_params'; import { isMetricAggType } from '../metrics/metric_agg_type'; @@ -35,6 +35,8 @@ import { FIELD_FORMAT_IDS, KBN_FIELD_TYPES } from '../../../../common'; import { TimefilterContract } from '../../../query'; import { QuerySetup } from '../../../query/query_service'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; +import { ExtendedBounds } from './lib/extended_bounds'; const detectedTimezone = moment.tz.guess(); const tzOffset = moment().format('Z'); @@ -67,6 +69,19 @@ export function isDateHistogramBucketAggConfig(agg: any): agg is IBucketDateHist return Boolean(agg.buckets); } +export interface AggParamsDateHistogram extends BaseAggParams { + field?: string; + timeRange?: TimeRange; + useNormalizedEsInterval?: boolean; + scaleMetricValues?: boolean; + interval?: string; + time_zone?: string; + drop_partials?: boolean; + format?: string; + min_doc_count?: number; + extended_bounds?: ExtendedBounds; +} + export const getDateHistogramBucketAgg = ({ uiSettings, query, @@ -89,6 +104,7 @@ export const getDateHistogramBucketAgg = ({ } const field = agg.getFieldDisplayName(); + return i18n.translate('data.search.aggs.buckets.dateHistogramLabel', { defaultMessage: '{fieldName} per {intervalDescription}', values: { diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.test.ts new file mode 100644 index 0000000000000..bd3c4f8dd58cf --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggDateHistogram } from './date_histogram_fn'; + +describe('agg_expression_functions', () => { + describe('aggDateHistogram', () => { + const fn = functionWrapper(aggDateHistogram()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({}); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "drop_partials": undefined, + "extended_bounds": undefined, + "field": undefined, + "format": undefined, + "interval": undefined, + "json": undefined, + "min_doc_count": undefined, + "scaleMetricValues": undefined, + "timeRange": undefined, + "time_zone": undefined, + "useNormalizedEsInterval": undefined, + }, + "schema": undefined, + "type": "date_histogram", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'field', + timeRange: JSON.stringify({ + from: 'from', + to: 'to', + }), + useNormalizedEsInterval: true, + scaleMetricValues: true, + interval: 'interval', + time_zone: 'time_zone', + drop_partials: false, + format: 'format', + min_doc_count: 1, + extended_bounds: JSON.stringify({ + min: 1, + max: 2, + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "drop_partials": false, + "extended_bounds": Object { + "max": 2, + "min": 1, + }, + "field": "field", + "format": "format", + "interval": "interval", + "json": undefined, + "min_doc_count": 1, + "scaleMetricValues": true, + "timeRange": Object { + "from": "from", + "to": "to", + }, + "time_zone": "time_zone", + "useNormalizedEsInterval": true, + }, + "schema": undefined, + "type": "date_histogram", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.ts b/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.ts new file mode 100644 index 0000000000000..033b44da0880f --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/date_histogram_fn.ts @@ -0,0 +1,155 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggDateHistogram'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggDateHistogram = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.dateHistogram.help', { + defaultMessage: 'Generates a serialized agg config for a Histogram agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.dateHistogram.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + useNormalizedEsInterval: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.useNormalizedEsInterval.help', { + defaultMessage: 'Specifies whether to use useNormalizedEsInterval for this aggregation', + }), + }, + time_zone: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.timeZone.help', { + defaultMessage: 'Time zone to use for this aggregation', + }), + }, + format: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.format.help', { + defaultMessage: 'Format to use for this aggregation', + }), + }, + scaleMetricValues: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.scaleMetricValues.help', { + defaultMessage: 'Specifies whether to use scaleMetricValues for this aggregation', + }), + }, + interval: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.interval.help', { + defaultMessage: 'Interval to use for this aggregation', + }), + }, + timeRange: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.timeRange.help', { + defaultMessage: 'Time Range to use for this aggregation', + }), + }, + min_doc_count: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.minDocCount.help', { + defaultMessage: 'Minimum document count to use for this aggregation', + }), + }, + drop_partials: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.dropPartials.help', { + defaultMessage: 'Specifies whether to use drop_partials for this aggregation', + }), + }, + extended_bounds: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.extendedBounds.help', { + defaultMessage: + 'With extended_bounds setting, you now can "force" the histogram aggregation to start building buckets on a specific min value and also keep on building buckets up to a max value ', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateHistogram.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.DATE_HISTOGRAM, + params: { + ...rest, + timeRange: getParsedValue(args, 'timeRange'), + extended_bounds: getParsedValue(args, 'extended_bounds'), + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/date_range.ts b/src/plugins/data/public/search/aggs/buckets/date_range.ts index 07d927e64a943..504958854cad4 100644 --- a/src/plugins/data/public/search/aggs/buckets/date_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/date_range.ts @@ -29,6 +29,7 @@ import { convertDateRangeToString, DateRangeKey } from './lib/date_range'; import { KBN_FIELD_TYPES, FieldFormat, TEXT_CONTEXT_TYPE } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const dateRangeTitle = i18n.translate('data.search.aggs.buckets.dateRangeTitle', { defaultMessage: 'Date Range', @@ -39,6 +40,12 @@ export interface DateRangeBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsDateRange extends BaseAggParams { + field?: string; + ranges?: DateRangeKey[]; + time_zone?: string; +} + export const getDateRangeBucketAgg = ({ uiSettings, getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/buckets/date_range_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/date_range_fn.test.ts new file mode 100644 index 0000000000000..93bb791874e67 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/date_range_fn.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggDateRange } from './date_range_fn'; + +describe('agg_expression_functions', () => { + describe('aggDateRange', () => { + const fn = functionWrapper(aggDateRange()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({}); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": undefined, + "json": undefined, + "ranges": undefined, + "time_zone": undefined, + }, + "schema": undefined, + "type": "date_range", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'date_field', + time_zone: 'UTC +3', + ranges: JSON.stringify([ + { from: 'now-1w/w', to: 'now' }, + { from: 1588163532470, to: 1588163532481 }, + ]), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "date_field", + "json": undefined, + "ranges": Array [ + Object { + "from": "now-1w/w", + "to": "now", + }, + Object { + "from": 1588163532470, + "to": 1588163532481, + }, + ], + "time_zone": "UTC +3", + }, + "schema": undefined, + "type": "date_range", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'date_field', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'date_field', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/date_range_fn.ts b/src/plugins/data/public/search/aggs/buckets/date_range_fn.ts new file mode 100644 index 0000000000000..1fe42ce63d815 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/date_range_fn.ts @@ -0,0 +1,111 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggDateRange'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggDateRange = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.dateRange.help', { + defaultMessage: 'Generates a serialized agg config for a Date Range agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.dateRange.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + ranges: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.ranges.help', { + defaultMessage: 'Serialized ranges to use for this aggregation.', + }), + }, + time_zone: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.timeZone.help', { + defaultMessage: 'Time zone to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.dateRange.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.DATE_RANGE, + params: { + ...rest, + json: getParsedValue(args, 'json'), + ranges: getParsedValue(args, 'ranges'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/filter.ts b/src/plugins/data/public/search/aggs/buckets/filter.ts index accbdf4dd783d..69157edad4f68 100644 --- a/src/plugins/data/public/search/aggs/buckets/filter.ts +++ b/src/plugins/data/public/search/aggs/buckets/filter.ts @@ -21,6 +21,8 @@ import { i18n } from '@kbn/i18n'; import { BucketAggType } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { GetInternalStartServicesFn } from '../../../types'; +import { GeoBoundingBox } from './lib/geo_point'; +import { BaseAggParams } from '../types'; const filterTitle = i18n.translate('data.search.aggs.buckets.filterTitle', { defaultMessage: 'Filter', @@ -30,6 +32,10 @@ export interface FilterBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsFilter extends BaseAggParams { + geo_bounding_box?: GeoBoundingBox; +} + export const getFilterBucketAgg = ({ getInternalStartServices }: FilterBucketAggDependencies) => new BucketAggType( { diff --git a/src/plugins/data/public/search/aggs/buckets/filter_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/filter_fn.test.ts new file mode 100644 index 0000000000000..c820a73b0a894 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/filter_fn.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggFilter } from './filter_fn'; + +describe('agg_expression_functions', () => { + describe('aggFilter', () => { + const fn = functionWrapper(aggFilter()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({}); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "geo_bounding_box": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "filter", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + geo_bounding_box: JSON.stringify({ + wkt: 'BBOX (-74.1, -71.12, 40.73, 40.01)', + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "geo_bounding_box": Object { + "wkt": "BBOX (-74.1, -71.12, 40.73, 40.01)", + }, + "json": undefined, + }, + "schema": undefined, + "type": "filter", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/filter_fn.ts b/src/plugins/data/public/search/aggs/buckets/filter_fn.ts new file mode 100644 index 0000000000000..4a7180fc86c71 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/filter_fn.ts @@ -0,0 +1,99 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggFilter'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggFilter = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.filter.help', { + defaultMessage: 'Generates a serialized agg config for a Filter agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.filter.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + geo_bounding_box: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.geoBoundingBox.help', { + defaultMessage: 'Filter results based on a point location within a bounding box', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filter.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.FILTER, + params: { + ...rest, + json: getParsedValue(args, 'json'), + geo_bounding_box: getParsedValue(args, 'geo_bounding_box'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/filters.ts b/src/plugins/data/public/search/aggs/buckets/filters.ts index fe013928bba65..8654645d46a9b 100644 --- a/src/plugins/data/public/search/aggs/buckets/filters.ts +++ b/src/plugins/data/public/search/aggs/buckets/filters.ts @@ -29,6 +29,7 @@ import { Storage } from '../../../../../../plugins/kibana_utils/public'; import { getEsQueryConfig, buildEsQuery, Query } from '../../../../common'; import { getQueryLog } from '../../../query'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const filtersTitle = i18n.translate('data.search.aggs.buckets.filtersTitle', { defaultMessage: 'Filters', @@ -47,6 +48,13 @@ export interface FiltersBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsFilters extends Omit { + filters?: Array<{ + input: Query; + label: string; + }>; +} + export const getFiltersBucketAgg = ({ uiSettings, getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/buckets/filters_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/filters_fn.test.ts new file mode 100644 index 0000000000000..99c4f7d8c2b65 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/filters_fn.test.ts @@ -0,0 +1,91 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggFilters } from './filters_fn'; + +describe('agg_expression_functions', () => { + describe('aggFilters', () => { + const fn = functionWrapper(aggFilters()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({}); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "filters": undefined, + "json": undefined, + }, + "schema": undefined, + "type": "filters", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + filters: JSON.stringify([ + { + query: 'query', + language: 'lucene', + label: 'test', + }, + ]), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "filters": Array [ + Object { + "label": "test", + "language": "lucene", + "query": "query", + }, + ], + "json": undefined, + }, + "schema": undefined, + "type": "filters", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/filters_fn.ts b/src/plugins/data/public/search/aggs/buckets/filters_fn.ts new file mode 100644 index 0000000000000..6ffd5369d7087 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/filters_fn.ts @@ -0,0 +1,93 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggFilters'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggFilters = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.filters.help', { + defaultMessage: 'Generates a serialized agg config for a Filter agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filters.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.filters.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filters.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + filters: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filters.filters.help', { + defaultMessage: 'Filters to use for this aggregation', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.filters.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.FILTERS, + params: { + ...rest, + filters: getParsedValue(args, 'filters'), + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/geo_hash.ts b/src/plugins/data/public/search/aggs/buckets/geo_hash.ts index eab10edad60f6..be339de5d7fae 100644 --- a/src/plugins/data/public/search/aggs/buckets/geo_hash.ts +++ b/src/plugins/data/public/search/aggs/buckets/geo_hash.ts @@ -22,6 +22,8 @@ import { BucketAggType, IBucketAggConfig } from './bucket_agg_type'; import { KBN_FIELD_TYPES } from '../../../../common'; import { BUCKET_TYPES } from './bucket_agg_types'; import { GetInternalStartServicesFn } from '../../../types'; +import { GeoBoundingBox } from './lib/geo_point'; +import { BaseAggParams } from '../types'; const defaultBoundingBox = { top_left: { lat: 1, lon: 1 }, @@ -38,6 +40,15 @@ export interface GeoHashBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsGeoHash extends BaseAggParams { + field: string; + autoPrecision?: boolean; + precision?: number; + useGeocentroid?: boolean; + isFilteredByCollar?: boolean; + boundingBox?: GeoBoundingBox; +} + export const getGeoHashBucketAgg = ({ getInternalStartServices }: GeoHashBucketAggDependencies) => new BucketAggType( { diff --git a/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.test.ts new file mode 100644 index 0000000000000..07ab8e66f1def --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.test.ts @@ -0,0 +1,112 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggGeoHash } from './geo_hash_fn'; + +describe('agg_expression_functions', () => { + describe('aggGeoHash', () => { + const fn = functionWrapper(aggGeoHash()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'geo_field', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "autoPrecision": undefined, + "boundingBox": undefined, + "customLabel": undefined, + "field": "geo_field", + "isFilteredByCollar": undefined, + "json": undefined, + "precision": undefined, + "useGeocentroid": undefined, + }, + "schema": undefined, + "type": "geohash_grid", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'geo_field', + autoPrecision: false, + precision: 10, + useGeocentroid: true, + isFilteredByCollar: false, + boundingBox: JSON.stringify({ + top_left: [-74.1, 40.73], + bottom_right: [-71.12, 40.01], + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "autoPrecision": false, + "boundingBox": Object { + "bottom_right": Array [ + -71.12, + 40.01, + ], + "top_left": Array [ + -74.1, + 40.73, + ], + }, + "customLabel": undefined, + "field": "geo_field", + "isFilteredByCollar": false, + "json": undefined, + "precision": 10, + "useGeocentroid": true, + }, + "schema": undefined, + "type": "geohash_grid", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'geo_field', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'geo_field', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.ts b/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.ts new file mode 100644 index 0000000000000..bbfa8575d486c --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/geo_hash_fn.ts @@ -0,0 +1,129 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggGeoHash'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggGeoHash = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.geoHash.help', { + defaultMessage: 'Generates a serialized agg config for a Geo Hash agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.geoHash.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.geoHash.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + useGeocentroid: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.geoHash.useGeocentroid.help', { + defaultMessage: 'Specifies whether to use geocentroid for this aggregation', + }), + }, + autoPrecision: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.geoHash.autoPrecision.help', { + defaultMessage: 'Specifies whether to use auto precision for this aggregation', + }), + }, + isFilteredByCollar: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.geoHash.isFilteredByCollar.help', { + defaultMessage: 'Specifies whether to filter by collar', + }), + }, + boundingBox: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.boundingBox.help', { + defaultMessage: 'Filter results based on a point location within a bounding box', + }), + }, + precision: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.geoHash.precision.help', { + defaultMessage: 'Precision to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoHash.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.GEOHASH_GRID, + params: { + ...rest, + boundingBox: getParsedValue(args, 'boundingBox'), + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/geo_tile.ts b/src/plugins/data/public/search/aggs/buckets/geo_tile.ts index c981e8400f9a1..1212bba23a93a 100644 --- a/src/plugins/data/public/search/aggs/buckets/geo_tile.ts +++ b/src/plugins/data/public/search/aggs/buckets/geo_tile.ts @@ -25,6 +25,7 @@ import { BUCKET_TYPES } from './bucket_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { METRIC_TYPES } from '../metrics/metric_agg_types'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; export interface GeoTitleBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; @@ -34,6 +35,12 @@ const geotileGridTitle = i18n.translate('data.search.aggs.buckets.geotileGridTit defaultMessage: 'Geotile', }); +export interface AggParamsGeoTile extends BaseAggParams { + field: string; + useGeocentroid?: boolean; + precision?: number; +} + export const getGeoTitleBucketAgg = ({ getInternalStartServices }: GeoTitleBucketAggDependencies) => new BucketAggType( { diff --git a/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.test.ts new file mode 100644 index 0000000000000..bfaf47ede8734 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.test.ts @@ -0,0 +1,91 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggGeoTile } from './geo_tile_fn'; + +describe('agg_expression_functions', () => { + describe('aggGeoTile', () => { + const fn = functionWrapper(aggGeoTile()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'geo_field', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "geo_field", + "json": undefined, + "precision": undefined, + "useGeocentroid": undefined, + }, + "schema": undefined, + "type": "geotile_grid", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'geo_field', + useGeocentroid: false, + precision: 10, + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "geo_field", + "json": undefined, + "precision": 10, + "useGeocentroid": false, + }, + "schema": undefined, + "type": "geotile_grid", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'geo_field', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'geo_field', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.ts b/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.ts new file mode 100644 index 0000000000000..9c33ef45762af --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/geo_tile_fn.ts @@ -0,0 +1,108 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggGeoTile'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggGeoTile = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.geoTile.help', { + defaultMessage: 'Generates a serialized agg config for a Geo Tile agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoTile.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.geoTile.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoTile.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.geoTile.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + useGeocentroid: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.geoTile.useGeocentroid.help', { + defaultMessage: 'Specifies whether to use geocentroid for this aggregation', + }), + }, + precision: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.geoTile.precision.help', { + defaultMessage: 'Precision to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoTile.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.geoTile.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.GEOTILE_GRID, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/histogram.ts b/src/plugins/data/public/search/aggs/buckets/histogram.ts index f8e8720d24ea9..d04df4f8aac6b 100644 --- a/src/plugins/data/public/search/aggs/buckets/histogram.ts +++ b/src/plugins/data/public/search/aggs/buckets/histogram.ts @@ -26,6 +26,8 @@ import { createFilterHistogram } from './create_filter/histogram'; import { BUCKET_TYPES } from './bucket_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; +import { ExtendedBounds } from './lib/extended_bounds'; export interface AutoBounds { min: number; @@ -42,6 +44,15 @@ export interface IBucketHistogramAggConfig extends IBucketAggConfig { getAutoBounds: () => AutoBounds; } +export interface AggParamsHistogram extends BaseAggParams { + field: string; + interval: string; + intervalBase?: number; + min_doc_count?: boolean; + has_extended_bounds?: boolean; + extended_bounds?: ExtendedBounds; +} + export const getHistogramBucketAgg = ({ uiSettings, getInternalStartServices, diff --git a/src/plugins/data/public/search/aggs/buckets/histogram_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/histogram_fn.test.ts new file mode 100644 index 0000000000000..34b6fa1a6dcd6 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/histogram_fn.test.ts @@ -0,0 +1,109 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggHistogram } from './histogram_fn'; + +describe('agg_expression_functions', () => { + describe('aggHistogram', () => { + const fn = functionWrapper(aggHistogram()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'field', + interval: '10', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "extended_bounds": undefined, + "field": "field", + "has_extended_bounds": undefined, + "interval": "10", + "intervalBase": undefined, + "json": undefined, + "min_doc_count": undefined, + }, + "schema": undefined, + "type": "histogram", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'field', + interval: '10', + intervalBase: 1, + min_doc_count: false, + has_extended_bounds: false, + extended_bounds: JSON.stringify({ + min: 1, + max: 2, + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "extended_bounds": Object { + "max": 2, + "min": 1, + }, + "field": "field", + "has_extended_bounds": false, + "interval": "10", + "intervalBase": 1, + "json": undefined, + "min_doc_count": false, + }, + "schema": undefined, + "type": "histogram", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'field', + interval: '10', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'field', + interval: '10', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/histogram_fn.ts b/src/plugins/data/public/search/aggs/buckets/histogram_fn.ts new file mode 100644 index 0000000000000..1e5a5a72c0ecb --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/histogram_fn.ts @@ -0,0 +1,132 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggHistogram'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggHistogram = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.histogram.help', { + defaultMessage: 'Generates a serialized agg config for a Histogram agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.histogram.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.histogram.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + interval: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.histogram.interval.help', { + defaultMessage: 'Interval to use for this aggregation', + }), + }, + intervalBase: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.histogram.intervalBase.help', { + defaultMessage: 'IntervalBase to use for this aggregation', + }), + }, + min_doc_count: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.histogram.minDocCount.help', { + defaultMessage: 'Specifies whether to use min_doc_count for this aggregation', + }), + }, + has_extended_bounds: { + types: ['boolean'], + help: i18n.translate('data.search.aggs.buckets.histogram.hasExtendedBounds.help', { + defaultMessage: 'Specifies whether to use has_extended_bounds for this aggregation', + }), + }, + extended_bounds: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.extendedBounds.help', { + defaultMessage: + 'With extended_bounds setting, you now can "force" the histogram aggregation to start building buckets on a specific min value and also keep on building buckets up to a max value ', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.histogram.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.HISTOGRAM, + params: { + ...rest, + extended_bounds: getParsedValue(args, 'extended_bounds'), + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/index.ts b/src/plugins/data/public/search/aggs/buckets/index.ts index 3a402b1498a77..7036cc7785db7 100644 --- a/src/plugins/data/public/search/aggs/buckets/index.ts +++ b/src/plugins/data/public/search/aggs/buckets/index.ts @@ -19,11 +19,18 @@ export * from './_interval_options'; export * from './bucket_agg_types'; +export * from './histogram'; export * from './date_histogram'; export * from './date_range'; +export * from './range'; +export * from './filter'; +export * from './filters'; +export * from './geo_tile'; +export * from './geo_hash'; export * from './ip_range'; export * from './lib/cidr_mask'; export * from './lib/date_range'; export * from './lib/ip_range'; export * from './migrate_include_exclude_format'; +export * from './significant_terms'; export * from './terms'; diff --git a/src/plugins/data/public/search/aggs/buckets/ip_range.ts b/src/plugins/data/public/search/aggs/buckets/ip_range.ts index bde347d6e673d..029fd864154be 100644 --- a/src/plugins/data/public/search/aggs/buckets/ip_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/ip_range.ts @@ -23,18 +23,38 @@ import { BucketAggType } from './bucket_agg_type'; import { BUCKET_TYPES } from './bucket_agg_types'; import { createFilterIpRange } from './create_filter/ip_range'; -import { IpRangeKey, convertIPRangeToString } from './lib/ip_range'; +import { + convertIPRangeToString, + IpRangeKey, + RangeIpRangeAggKey, + CidrMaskIpRangeAggKey, +} from './lib/ip_range'; import { KBN_FIELD_TYPES, FieldFormat, TEXT_CONTEXT_TYPE } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const ipRangeTitle = i18n.translate('data.search.aggs.buckets.ipRangeTitle', { defaultMessage: 'IPv4 Range', }); +export enum IP_RANGE_TYPES { + FROM_TO = 'fromTo', + MASK = 'mask', +} + export interface IpRangeBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsIpRange extends BaseAggParams { + field: string; + ipRangeType?: IP_RANGE_TYPES; + ranges?: Partial<{ + [IP_RANGE_TYPES.FROM_TO]: RangeIpRangeAggKey[]; + [IP_RANGE_TYPES.MASK]: CidrMaskIpRangeAggKey[]; + }>; +} + export const getIpRangeBucketAgg = ({ getInternalStartServices }: IpRangeBucketAggDependencies) => new BucketAggType( { @@ -42,7 +62,7 @@ export const getIpRangeBucketAgg = ({ getInternalStartServices }: IpRangeBucketA title: ipRangeTitle, createFilter: createFilterIpRange, getKey(bucket, key, agg): IpRangeKey { - if (agg.params.ipRangeType === 'mask') { + if (agg.params.ipRangeType === IP_RANGE_TYPES.MASK) { return { type: 'mask', mask: key }; } return { type: 'range', from: bucket.from, to: bucket.to }; @@ -74,7 +94,7 @@ export const getIpRangeBucketAgg = ({ getInternalStartServices }: IpRangeBucketA }, { name: 'ipRangeType', - default: 'fromTo', + default: IP_RANGE_TYPES.FROM_TO, write: noop, }, { @@ -90,7 +110,7 @@ export const getIpRangeBucketAgg = ({ getInternalStartServices }: IpRangeBucketA const ipRangeType = aggConfig.params.ipRangeType; let ranges = aggConfig.params.ranges[ipRangeType]; - if (ipRangeType === 'fromTo') { + if (ipRangeType === IP_RANGE_TYPES.FROM_TO) { ranges = map(ranges, (range: any) => omit(range, isNull)); } diff --git a/src/plugins/data/public/search/aggs/buckets/ip_range_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/ip_range_fn.test.ts new file mode 100644 index 0000000000000..5940345b25890 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/ip_range_fn.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { IP_RANGE_TYPES } from './ip_range'; +import { aggIpRange } from './ip_range_fn'; + +describe('agg_expression_functions', () => { + describe('aggIpRange', () => { + const fn = functionWrapper(aggIpRange()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'ip_field', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "ip_field", + "ipRangeType": undefined, + "json": undefined, + "ranges": undefined, + }, + "schema": undefined, + "type": "ip_range", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'ip_field', + ipRangeType: IP_RANGE_TYPES.MASK, + ranges: JSON.stringify({ + mask: [{ mask: '10.0.0.0/25' }], + }), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "ip_field", + "ipRangeType": "mask", + "json": undefined, + "ranges": Object { + "mask": Array [ + Object { + "mask": "10.0.0.0/25", + }, + ], + }, + }, + "schema": undefined, + "type": "ip_range", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'ip_field', + ipRangeType: IP_RANGE_TYPES.MASK, + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'ip_field', + ipRangeType: IP_RANGE_TYPES.FROM_TO, + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/ip_range_fn.ts b/src/plugins/data/public/search/aggs/buckets/ip_range_fn.ts new file mode 100644 index 0000000000000..554a8708d9164 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/ip_range_fn.ts @@ -0,0 +1,114 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggIpRange'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggIpRange = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.ipRange.help', { + defaultMessage: 'Generates a serialized agg config for a Ip Range agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.ipRange.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.ipRange.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + ipRangeType: { + types: ['string'], + options: ['mask', 'fromTo'], + help: i18n.translate('data.search.aggs.buckets.ipRange.ipRangeType.help', { + defaultMessage: + 'IP range type to use for this aggregation. Takes one of the following values: mask, fromTo.', + }), + }, + ranges: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.ranges.help', { + defaultMessage: 'Serialized ranges to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.ipRange.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.IP_RANGE, + params: { + ...rest, + json: getParsedValue(args, 'json'), + ranges: getParsedValue(args, 'ranges'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/lib/date_range.ts b/src/plugins/data/public/search/aggs/buckets/lib/date_range.ts index 6eb9fe8414ec8..d52bdff993a2b 100644 --- a/src/plugins/data/public/search/aggs/buckets/lib/date_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/lib/date_range.ts @@ -18,8 +18,8 @@ */ export interface DateRangeKey { - from: number; - to: number; + from: number | string; + to: number | string; } export function convertDateRangeToString({ from, to }: DateRangeKey, format: (val: any) => string) { diff --git a/src/plugins/data/public/search/aggs/buckets/lib/extended_bounds.ts b/src/plugins/data/public/search/aggs/buckets/lib/extended_bounds.ts new file mode 100644 index 0000000000000..7a249a9daca91 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/lib/extended_bounds.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. + */ + +export interface ExtendedBounds { + min: number; + max: number; +} diff --git a/src/plugins/data/public/search/aggs/buckets/lib/geo_point.ts b/src/plugins/data/public/search/aggs/buckets/lib/geo_point.ts new file mode 100644 index 0000000000000..8ff4493e286cf --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/lib/geo_point.ts @@ -0,0 +1,86 @@ +/* + * 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. + */ + +type GeoPoint = + | { + lat: number; + lon: number; + } + | string + | [number, number]; + +interface GeoBox { + top: number; + left: number; + bottom: number; + right: number; +} + +/** GeoBoundingBox Accepted Formats: + * Lat Lon As Properties: + * "top_left" : { + * "lat" : 40.73, "lon" : -74.1 + * }, + * "bottom_right" : { + * "lat" : 40.01, "lon" : -71.12 + * } + * + * Lat Lon As Array: + * { + * "top_left" : [-74.1, 40.73], + * "bottom_right" : [-71.12, 40.01] + * } + * + * Lat Lon As String: + * { + * "top_left" : "40.73, -74.1", + * "bottom_right" : "40.01, -71.12" + * } + * + * Bounding Box as Well-Known Text (WKT): + * { + * "wkt" : "BBOX (-74.1, -71.12, 40.73, 40.01)" + * } + * + * Geohash: + * { + * "top_right" : "dr5r9ydj2y73", + * "bottom_left" : "drj7teegpus6" + * } + * + * Vertices: + * { + * "top" : 40.73, + * "left" : -74.1, + * "bottom" : 40.01, + * "right" : -71.12 + * } + * + * **/ +export type GeoBoundingBox = + | Partial<{ + top_left: GeoPoint; + top_right: GeoPoint; + bottom_right: GeoPoint; + bottom_left: GeoPoint; + }> + | { + wkt: string; + } + | GeoBox; diff --git a/src/plugins/data/public/search/aggs/buckets/lib/ip_range.ts b/src/plugins/data/public/search/aggs/buckets/lib/ip_range.ts index be1ac28934c7c..57e5337d4c365 100644 --- a/src/plugins/data/public/search/aggs/buckets/lib/ip_range.ts +++ b/src/plugins/data/public/search/aggs/buckets/lib/ip_range.ts @@ -17,9 +17,18 @@ * under the License. */ -export type IpRangeKey = - | { type: 'mask'; mask: string } - | { type: 'range'; from: string; to: string }; +export interface CidrMaskIpRangeAggKey { + type: 'mask'; + mask: string; +} + +export interface RangeIpRangeAggKey { + type: 'range'; + from: string; + to: string; +} + +export type IpRangeKey = CidrMaskIpRangeAggKey | RangeIpRangeAggKey; export const convertIPRangeToString = (range: IpRangeKey, format: (val: any) => string) => { if (range.type === 'mask') { diff --git a/src/plugins/data/public/search/aggs/buckets/range.ts b/src/plugins/data/public/search/aggs/buckets/range.ts index 2c1303814a88a..02aad3bd5fed1 100644 --- a/src/plugins/data/public/search/aggs/buckets/range.ts +++ b/src/plugins/data/public/search/aggs/buckets/range.ts @@ -24,6 +24,7 @@ import { RangeKey } from './range_key'; import { createFilterRange } from './create_filter/range'; import { BUCKET_TYPES } from './bucket_agg_types'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const keyCaches = new WeakMap(); const formats = new WeakMap(); @@ -36,6 +37,14 @@ export interface RangeBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsRange extends BaseAggParams { + field: string; + ranges?: Array<{ + from: number; + to: number; + }>; +} + export const getRangeBucketAgg = ({ getInternalStartServices }: RangeBucketAggDependencies) => new BucketAggType( { diff --git a/src/plugins/data/public/search/aggs/buckets/range_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/range_fn.test.ts new file mode 100644 index 0000000000000..93ae4490196a8 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/range_fn.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { functionWrapper } from '../test_helpers'; +import { aggRange } from './range_fn'; + +describe('agg_expression_functions', () => { + describe('aggRange', () => { + const fn = functionWrapper(aggRange()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'number_field', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "number_field", + "json": undefined, + "ranges": undefined, + }, + "schema": undefined, + "type": "range", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + field: 'number_field', + ranges: JSON.stringify([ + { from: 1, to: 2 }, + { from: 5, to: 100 }, + ]), + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "field": "number_field", + "json": undefined, + "ranges": Array [ + Object { + "from": 1, + "to": 2, + }, + Object { + "from": 5, + "to": 100, + }, + ], + }, + "schema": undefined, + "type": "range", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'number_field', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + + expect(() => { + fn({ + field: 'number_field', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/range_fn.ts b/src/plugins/data/public/search/aggs/buckets/range_fn.ts new file mode 100644 index 0000000000000..48686e7061de9 --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/range_fn.ts @@ -0,0 +1,106 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Assign } from '@kbn/utility-types'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggRange'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = Assign; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggRange = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.range.help', { + defaultMessage: 'Generates a serialized agg config for a Range agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.range.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.range.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + ranges: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.ranges.help', { + defaultMessage: 'Serialized ranges to use for this aggregation.', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.range.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.RANGE, + params: { + ...rest, + json: getParsedValue(args, 'json'), + ranges: getParsedValue(args, 'ranges'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/significant_terms.ts b/src/plugins/data/public/search/aggs/buckets/significant_terms.ts index 49d797f3afbc9..e6afc56dfd31c 100644 --- a/src/plugins/data/public/search/aggs/buckets/significant_terms.ts +++ b/src/plugins/data/public/search/aggs/buckets/significant_terms.ts @@ -24,6 +24,7 @@ import { isStringType, migrateIncludeExcludeFormat } from './migrate_include_exc import { BUCKET_TYPES } from './bucket_agg_types'; import { KBN_FIELD_TYPES } from '../../../../common'; import { GetInternalStartServicesFn } from '../../../types'; +import { BaseAggParams } from '../types'; const significantTermsTitle = i18n.translate('data.search.aggs.buckets.significantTermsTitle', { defaultMessage: 'Significant Terms', @@ -33,6 +34,13 @@ export interface SignificantTermsBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } +export interface AggParamsSignificantTerms extends BaseAggParams { + field: string; + size?: number; + exclude?: string; + include?: string; +} + export const getSignificantTermsBucketAgg = ({ getInternalStartServices, }: SignificantTermsBucketAggDependencies) => diff --git a/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.test.ts new file mode 100644 index 0000000000000..71be4e9cfa9ac --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.test.ts @@ -0,0 +1,96 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { functionWrapper } from '../test_helpers'; +import { aggSignificantTerms } from './significant_terms_fn'; + +describe('agg_expression_functions', () => { + describe('aggSignificantTerms', () => { + const fn = functionWrapper(aggSignificantTerms()); + + test('fills in defaults when only required args are provided', () => { + const actual = fn({ + field: 'machine.os.keyword', + }); + expect(actual).toMatchInlineSnapshot(` + Object { + "type": "agg_type", + "value": Object { + "enabled": true, + "id": undefined, + "params": Object { + "customLabel": undefined, + "exclude": undefined, + "field": "machine.os.keyword", + "include": undefined, + "json": undefined, + "size": undefined, + }, + "schema": undefined, + "type": "significant_terms", + }, + } + `); + }); + + test('includes optional params when they are provided', () => { + const actual = fn({ + id: '1', + enabled: false, + schema: 'whatever', + field: 'machine.os.keyword', + size: 6, + include: 'win', + exclude: 'ios', + }); + + expect(actual.value).toMatchInlineSnapshot(` + Object { + "enabled": false, + "id": "1", + "params": Object { + "customLabel": undefined, + "exclude": "ios", + "field": "machine.os.keyword", + "include": "win", + "json": undefined, + "size": 6, + }, + "schema": "whatever", + "type": "significant_terms", + } + `); + }); + + test('correctly parses json string argument', () => { + const actual = fn({ + field: 'machine.os.keyword', + json: '{ "foo": true }', + }); + + expect(actual.value.params.json).toEqual({ foo: true }); + expect(() => { + fn({ + field: 'machine.os.keyword', + json: '/// intentionally malformed json ///', + }); + }).toThrowErrorMatchingInlineSnapshot(`"Unable to parse json argument string"`); + }); + }); +}); diff --git a/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.ts b/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.ts new file mode 100644 index 0000000000000..83583070bddfe --- /dev/null +++ b/src/plugins/data/public/search/aggs/buckets/significant_terms_fn.ts @@ -0,0 +1,116 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; + +const fnName = 'aggSignificantTerms'; + +type Input = any; +type AggArgs = AggExpressionFunctionArgs; + +type Arguments = AggArgs; + +type Output = AggExpressionType; +type FunctionDefinition = ExpressionFunctionDefinition; + +export const aggSignificantTerms = (): FunctionDefinition => ({ + name: fnName, + help: i18n.translate('data.search.aggs.function.buckets.significantTerms.help', { + defaultMessage: 'Generates a serialized agg config for a Significant Terms agg', + }), + type: 'agg_type', + args: { + id: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.id.help', { + defaultMessage: 'ID for this aggregation', + }), + }, + enabled: { + types: ['boolean'], + default: true, + help: i18n.translate('data.search.aggs.buckets.significantTerms.enabled.help', { + defaultMessage: 'Specifies whether this aggregation should be enabled', + }), + }, + schema: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.schema.help', { + defaultMessage: 'Schema to use for this aggregation', + }), + }, + field: { + types: ['string'], + required: true, + help: i18n.translate('data.search.aggs.buckets.significantTerms.field.help', { + defaultMessage: 'Field to use for this aggregation', + }), + }, + size: { + types: ['number'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.size.help', { + defaultMessage: 'Max number of buckets to retrieve', + }), + }, + exclude: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.exclude.help', { + defaultMessage: 'Specific bucket values to exclude from results', + }), + }, + include: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.include.help', { + defaultMessage: 'Specific bucket values to include in results', + }), + }, + json: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.json.help', { + defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', + }), + }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.significantTerms.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, + }, + fn: (input, args) => { + const { id, enabled, schema, ...rest } = args; + + return { + type: 'agg_type', + value: { + id, + enabled, + schema, + type: BUCKET_TYPES.SIGNIFICANT_TERMS, + params: { + ...rest, + json: getParsedValue(args, 'json'), + }, + }, + }; + }, +}); diff --git a/src/plugins/data/public/search/aggs/buckets/terms.ts b/src/plugins/data/public/search/aggs/buckets/terms.ts index a12a1d7ac2d3d..1bfc508dc3871 100644 --- a/src/plugins/data/public/search/aggs/buckets/terms.ts +++ b/src/plugins/data/public/search/aggs/buckets/terms.ts @@ -26,7 +26,7 @@ import { isStringOrNumberType, migrateIncludeExcludeFormat, } from './migrate_include_exclude_format'; -import { AggConfigSerialized, IAggConfigs } from '../types'; +import { AggConfigSerialized, BaseAggParams, IAggConfigs } from '../types'; import { Adapters } from '../../../../../inspector/public'; import { ISearchSource } from '../../search_source'; @@ -63,11 +63,11 @@ export interface TermsBucketAggDependencies { getInternalStartServices: GetInternalStartServicesFn; } -export interface AggParamsTerms { +export interface AggParamsTerms extends BaseAggParams { field: string; - order: 'asc' | 'desc'; orderBy: string; orderAgg?: AggConfigSerialized; + order?: 'asc' | 'desc'; size?: number; missingBucket?: boolean; missingBucketLabel?: string; @@ -76,7 +76,6 @@ export interface AggParamsTerms { // advanced exclude?: string; include?: string; - json?: string; } export const getTermsBucketAgg = ({ getInternalStartServices }: TermsBucketAggDependencies) => diff --git a/src/plugins/data/public/search/aggs/buckets/terms_fn.test.ts b/src/plugins/data/public/search/aggs/buckets/terms_fn.test.ts index f55f1de796013..1384a9f17e4b6 100644 --- a/src/plugins/data/public/search/aggs/buckets/terms_fn.test.ts +++ b/src/plugins/data/public/search/aggs/buckets/terms_fn.test.ts @@ -27,7 +27,6 @@ describe('agg_expression_functions', () => { test('fills in defaults when only required args are provided', () => { const actual = fn({ field: 'machine.os.keyword', - order: 'asc', orderBy: '1', }); expect(actual).toMatchInlineSnapshot(` @@ -37,18 +36,19 @@ describe('agg_expression_functions', () => { "enabled": true, "id": undefined, "params": Object { + "customLabel": undefined, "exclude": undefined, "field": "machine.os.keyword", "include": undefined, "json": undefined, - "missingBucket": false, - "missingBucketLabel": "Missing", - "order": "asc", + "missingBucket": undefined, + "missingBucketLabel": undefined, + "order": undefined, "orderAgg": undefined, "orderBy": "1", - "otherBucket": false, - "otherBucketLabel": "Other", - "size": 5, + "otherBucket": undefined, + "otherBucketLabel": undefined, + "size": undefined, }, "schema": undefined, "type": "terms", @@ -70,6 +70,7 @@ describe('agg_expression_functions', () => { missingBucketLabel: 'missing', otherBucket: true, otherBucketLabel: 'other', + include: 'win', exclude: 'ios', }); @@ -78,9 +79,10 @@ describe('agg_expression_functions', () => { "enabled": false, "id": "1", "params": Object { + "customLabel": undefined, "exclude": "ios", "field": "machine.os.keyword", - "include": undefined, + "include": "win", "json": undefined, "missingBucket": true, "missingBucketLabel": "missing", @@ -107,37 +109,39 @@ describe('agg_expression_functions', () => { expect(actual.value.params).toMatchInlineSnapshot(` Object { + "customLabel": undefined, "exclude": undefined, "field": "machine.os.keyword", "include": undefined, "json": undefined, - "missingBucket": false, - "missingBucketLabel": "Missing", + "missingBucket": undefined, + "missingBucketLabel": undefined, "order": "asc", "orderAgg": Object { "enabled": true, "id": undefined, "params": Object { + "customLabel": undefined, "exclude": undefined, "field": "name", "include": undefined, "json": undefined, - "missingBucket": false, - "missingBucketLabel": "Missing", + "missingBucket": undefined, + "missingBucketLabel": undefined, "order": "asc", "orderAgg": undefined, "orderBy": "1", - "otherBucket": false, - "otherBucketLabel": "Other", - "size": 5, + "otherBucket": undefined, + "otherBucketLabel": undefined, + "size": undefined, }, "schema": undefined, "type": "terms", }, "orderBy": "1", - "otherBucket": false, - "otherBucketLabel": "Other", - "size": 5, + "otherBucket": undefined, + "otherBucketLabel": undefined, + "size": undefined, } `); }); diff --git a/src/plugins/data/public/search/aggs/buckets/terms_fn.ts b/src/plugins/data/public/search/aggs/buckets/terms_fn.ts index 7980bfabe79fb..49520863fe1cc 100644 --- a/src/plugins/data/public/search/aggs/buckets/terms_fn.ts +++ b/src/plugins/data/public/search/aggs/buckets/terms_fn.ts @@ -20,27 +20,25 @@ import { i18n } from '@kbn/i18n'; import { Assign } from '@kbn/utility-types'; import { ExpressionFunctionDefinition } from '../../../../../expressions/public'; -import { AggExpressionType, AggExpressionFunctionArgs } from '../'; +import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '../'; +import { getParsedValue } from '../utils/get_parsed_value'; -const aggName = 'terms'; const fnName = 'aggTerms'; type Input = any; -type AggArgs = AggExpressionFunctionArgs; +type AggArgs = AggExpressionFunctionArgs; + // Since the orderAgg param is an agg nested in a subexpression, we need to // overwrite the param type to expect a value of type AggExpressionType. -type Arguments = AggArgs & - Assign< - AggArgs, - { orderAgg?: AggArgs['orderAgg'] extends undefined ? undefined : AggExpressionType } - >; +type Arguments = Assign; + type Output = AggExpressionType; type FunctionDefinition = ExpressionFunctionDefinition; export const aggTerms = (): FunctionDefinition => ({ name: fnName, help: i18n.translate('data.search.aggs.function.buckets.terms.help', { - defaultMessage: 'Generates a serialized agg config for a terms agg', + defaultMessage: 'Generates a serialized agg config for a Terms agg', }), type: 'agg_type', args: { @@ -72,7 +70,7 @@ export const aggTerms = (): FunctionDefinition => ({ }, order: { types: ['string'], - required: true, + options: ['asc', 'desc'], help: i18n.translate('data.search.aggs.buckets.terms.order.help', { defaultMessage: 'Order in which to return the results: asc or desc', }), @@ -91,41 +89,30 @@ export const aggTerms = (): FunctionDefinition => ({ }, size: { types: ['number'], - default: 5, help: i18n.translate('data.search.aggs.buckets.terms.size.help', { defaultMessage: 'Max number of buckets to retrieve', }), }, missingBucket: { types: ['boolean'], - default: false, help: i18n.translate('data.search.aggs.buckets.terms.missingBucket.help', { defaultMessage: 'When set to true, groups together any buckets with missing fields', }), }, missingBucketLabel: { types: ['string'], - default: i18n.translate('data.search.aggs.buckets.terms.missingBucketLabel', { - defaultMessage: 'Missing', - description: `Default label used in charts when documents are missing a field. - Visible when you create a chart with a terms aggregation and enable "Show missing values"`, - }), help: i18n.translate('data.search.aggs.buckets.terms.missingBucketLabel.help', { defaultMessage: 'Default label used in charts when documents are missing a field.', }), }, otherBucket: { types: ['boolean'], - default: false, help: i18n.translate('data.search.aggs.buckets.terms.otherBucket.help', { defaultMessage: 'When set to true, groups together any buckets beyond the allowed size', }), }, otherBucketLabel: { types: ['string'], - default: i18n.translate('data.search.aggs.buckets.terms.otherBucketLabel', { - defaultMessage: 'Other', - }), help: i18n.translate('data.search.aggs.buckets.terms.otherBucketLabel.help', { defaultMessage: 'Default label used in charts for documents in the Other bucket', }), @@ -148,32 +135,27 @@ export const aggTerms = (): FunctionDefinition => ({ defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch', }), }, + customLabel: { + types: ['string'], + help: i18n.translate('data.search.aggs.buckets.terms.customLabel.help', { + defaultMessage: 'Represents a custom label for this aggregation', + }), + }, }, fn: (input, args) => { const { id, enabled, schema, ...rest } = args; - let json; - try { - json = args.json ? JSON.parse(args.json) : undefined; - } catch (e) { - throw new Error('Unable to parse json argument string'); - } - - // Need to spread this object to work around TS bug: - // https://github.com/microsoft/TypeScript/issues/15300#issuecomment-436793742 - const orderAgg = args.orderAgg?.value ? { ...args.orderAgg.value } : undefined; - return { type: 'agg_type', value: { id, enabled, schema, - type: aggName, + type: BUCKET_TYPES.TERMS, params: { ...rest, - orderAgg, - json, + orderAgg: args.orderAgg?.value, + json: getParsedValue(args, 'json'), }, }, }; diff --git a/src/plugins/data/public/search/aggs/filter/agg_type_filters.test.ts b/src/plugins/data/public/search/aggs/filter/agg_type_filters.test.ts deleted file mode 100644 index 58f5aef0b9dfd..0000000000000 --- a/src/plugins/data/public/search/aggs/filter/agg_type_filters.test.ts +++ /dev/null @@ -1,62 +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 { IndexPattern } from '../../../index_patterns'; -import { AggTypeFilters } from './agg_type_filters'; -import { IAggConfig, IAggType } from '../types'; - -describe('AggTypeFilters', () => { - let registry: AggTypeFilters; - const indexPattern = ({ id: '1234', fields: [], title: 'foo' } as unknown) as IndexPattern; - const aggConfig = {} as IAggConfig; - - beforeEach(() => { - registry = new AggTypeFilters(); - }); - - it('should filter nothing without registered filters', async () => { - const aggTypes = [{ name: 'count' }, { name: 'sum' }] as IAggType[]; - const filtered = registry.filter(aggTypes, indexPattern, aggConfig, []); - expect(filtered).toEqual(aggTypes); - }); - - it('should pass all aggTypes to the registered filter', async () => { - const aggTypes = [{ name: 'count' }, { name: 'sum' }] as IAggType[]; - const filter = jest.fn(); - registry.addFilter(filter); - registry.filter(aggTypes, indexPattern, aggConfig, []); - expect(filter).toHaveBeenCalledWith(aggTypes[0], indexPattern, aggConfig, []); - expect(filter).toHaveBeenCalledWith(aggTypes[1], indexPattern, aggConfig, []); - }); - - it('should allow registered filters to filter out aggTypes', async () => { - const aggTypes = [{ name: 'count' }, { name: 'sum' }, { name: 'avg' }] as IAggType[]; - let filtered = registry.filter(aggTypes, indexPattern, aggConfig, []); - expect(filtered).toEqual(aggTypes); - - registry.addFilter(() => true); - registry.addFilter(aggType => aggType.name !== 'count'); - filtered = registry.filter(aggTypes, indexPattern, aggConfig, []); - expect(filtered).toEqual([aggTypes[1], aggTypes[2]]); - - registry.addFilter(aggType => aggType.name !== 'avg'); - filtered = registry.filter(aggTypes, indexPattern, aggConfig, []); - expect(filtered).toEqual([aggTypes[1]]); - }); -}); diff --git a/src/plugins/data/public/search/aggs/filter/agg_type_filters.ts b/src/plugins/data/public/search/aggs/filter/agg_type_filters.ts deleted file mode 100644 index b8d192cd66b5a..0000000000000 --- a/src/plugins/data/public/search/aggs/filter/agg_type_filters.ts +++ /dev/null @@ -1,74 +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 { IndexPattern } from '../../../index_patterns'; -import { IAggConfig, IAggType } from '../types'; - -type AggTypeFilter = ( - aggType: IAggType, - indexPattern: IndexPattern, - aggConfig: IAggConfig, - aggFilter: string[] -) => boolean; - -/** - * A registry to store {@link AggTypeFilter} which are used to filter down - * available aggregations for a specific visualization and {@link AggConfig}. - */ -class AggTypeFilters { - private filters = new Set(); - - /** - * Register a new {@link AggTypeFilter} with this registry. - * - * @param filter The filter to register. - */ - public addFilter(filter: AggTypeFilter): void { - this.filters.add(filter); - } - - /** - * Returns the {@link AggType|aggTypes} filtered by all registered filters. - * - * @param aggTypes A list of aggTypes that will be filtered down by this registry. - * @param indexPattern The indexPattern for which this list should be filtered down. - * @param aggConfig The aggConfig for which the returning list will be used. - * @param schema - * @return A filtered list of the passed aggTypes. - */ - public filter( - aggTypes: IAggType[], - indexPattern: IndexPattern, - aggConfig: IAggConfig, - aggFilter: string[] - ) { - const allFilters = Array.from(this.filters); - const allowedAggTypes = aggTypes.filter(aggType => { - const isAggTypeAllowed = allFilters.every(filter => - filter(aggType, indexPattern, aggConfig, aggFilter) - ); - return isAggTypeAllowed; - }); - return allowedAggTypes; - } -} - -const aggTypeFilters = new AggTypeFilters(); - -export { aggTypeFilters, AggTypeFilters }; diff --git a/src/plugins/data/public/search/aggs/filter/index.ts b/src/plugins/data/public/search/aggs/filter/index.ts deleted file mode 100644 index 35d06807d0ec2..0000000000000 --- a/src/plugins/data/public/search/aggs/filter/index.ts +++ /dev/null @@ -1,21 +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. - */ - -export { aggTypeFilters, AggTypeFilters } from './agg_type_filters'; -export { propFilter } from './prop_filter'; diff --git a/src/plugins/data/public/search/aggs/index.ts b/src/plugins/data/public/search/aggs/index.ts index 5dfb6aeff8d14..1139d9c7ff722 100644 --- a/src/plugins/data/public/search/aggs/index.ts +++ b/src/plugins/data/public/search/aggs/index.ts @@ -24,7 +24,6 @@ export * from './agg_type'; export * from './agg_types'; export * from './agg_types_registry'; export * from './buckets'; -export * from './filter'; export * from './metrics'; export * from './param_types'; export * from './types'; diff --git a/src/plugins/data/public/search/aggs/param_types/field.ts b/src/plugins/data/public/search/aggs/param_types/field.ts index 4d67f41905c5a..63dbed9cec612 100644 --- a/src/plugins/data/public/search/aggs/param_types/field.ts +++ b/src/plugins/data/public/search/aggs/param_types/field.ts @@ -21,7 +21,7 @@ import { i18n } from '@kbn/i18n'; import { IAggConfig } from '../agg_config'; import { SavedObjectNotFound } from '../../../../../../plugins/kibana_utils/public'; import { BaseParamType } from './base'; -import { propFilter } from '../filter'; +import { propFilter } from '../utils'; import { isNestedField, KBN_FIELD_TYPES } from '../../../../common'; import { Field as IndexPatternField } from '../../../index_patterns'; import { GetInternalStartServicesFn } from '../../../types'; diff --git a/src/plugins/data/public/search/aggs/param_types/filter/field_filters.test.ts b/src/plugins/data/public/search/aggs/param_types/filter/field_filters.test.ts deleted file mode 100644 index f776a3deb23a1..0000000000000 --- a/src/plugins/data/public/search/aggs/param_types/filter/field_filters.test.ts +++ /dev/null @@ -1,61 +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 { AggTypeFieldFilters } from './field_filters'; -import { IAggConfig } from '../../agg_config'; -import { Field as IndexPatternField } from '../../../../index_patterns'; - -describe('AggTypeFieldFilters', () => { - let registry: AggTypeFieldFilters; - const aggConfig = {} as IAggConfig; - - beforeEach(() => { - registry = new AggTypeFieldFilters(); - }); - - it('should filter nothing without registered filters', async () => { - const fields = [{ name: 'foo' }, { name: 'bar' }] as IndexPatternField[]; - const filtered = registry.filter(fields, aggConfig); - expect(filtered).toEqual(fields); - }); - - it('should pass all fields to the registered filter', async () => { - const fields = [{ name: 'foo' }, { name: 'bar' }] as IndexPatternField[]; - const filter = jest.fn(); - registry.addFilter(filter); - registry.filter(fields, aggConfig); - expect(filter).toHaveBeenCalledWith(fields[0], aggConfig); - expect(filter).toHaveBeenCalledWith(fields[1], aggConfig); - }); - - it('should allow registered filters to filter out fields', async () => { - const fields = [{ name: 'foo' }, { name: 'bar' }] as IndexPatternField[]; - let filtered = registry.filter(fields, aggConfig); - expect(filtered).toEqual(fields); - - registry.addFilter(() => true); - registry.addFilter(field => field.name !== 'foo'); - filtered = registry.filter(fields, aggConfig); - expect(filtered).toEqual([fields[1]]); - - registry.addFilter(field => field.name !== 'bar'); - filtered = registry.filter(fields, aggConfig); - expect(filtered).toEqual([]); - }); -}); diff --git a/src/plugins/data/public/search/aggs/param_types/filter/field_filters.ts b/src/plugins/data/public/search/aggs/param_types/filter/field_filters.ts deleted file mode 100644 index 1cbf0c9ae3624..0000000000000 --- a/src/plugins/data/public/search/aggs/param_types/filter/field_filters.ts +++ /dev/null @@ -1,60 +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 { IndexPatternField } from 'src/plugins/data/public'; -import { IAggConfig } from '../../agg_config'; - -type AggTypeFieldFilter = (field: IndexPatternField, aggConfig: IAggConfig) => boolean; - -/** - * A registry to store {@link AggTypeFieldFilter} which are used to filter down - * available fields for a specific visualization and {@link AggType}. - */ -class AggTypeFieldFilters { - private filters = new Set(); - - /** - * Register a new {@link AggTypeFieldFilter} with this registry. - * This will be used by the {@link #filter|filter method}. - * - * @param filter The filter to register. - */ - public addFilter(filter: AggTypeFieldFilter): void { - this.filters.add(filter); - } - - /** - * Returns the {@link any|fields} filtered by all registered filters. - * - * @param fields An array of fields that will be filtered down by this registry. - * @param aggConfig The aggConfig for which the returning list will be used. - * @return A filtered list of the passed fields. - */ - public filter(fields: IndexPatternField[], aggConfig: IAggConfig) { - const allFilters = Array.from(this.filters); - const allowedAggTypeFields = fields.filter(field => { - const isAggTypeFieldAllowed = allFilters.every(filter => filter(field, aggConfig)); - return isAggTypeFieldAllowed; - }); - return allowedAggTypeFields; - } -} - -const aggTypeFieldFilters = new AggTypeFieldFilters(); - -export { aggTypeFieldFilters, AggTypeFieldFilters }; diff --git a/src/plugins/data/public/search/aggs/param_types/filter/index.ts b/src/plugins/data/public/search/aggs/param_types/filter/index.ts deleted file mode 100644 index 2e0039c96a192..0000000000000 --- a/src/plugins/data/public/search/aggs/param_types/filter/index.ts +++ /dev/null @@ -1,20 +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. - */ - -export { aggTypeFieldFilters, AggTypeFieldFilters } from './field_filters'; diff --git a/src/plugins/data/public/search/aggs/param_types/index.ts b/src/plugins/data/public/search/aggs/param_types/index.ts index c9e8a9879f427..e25dd55dbd3f2 100644 --- a/src/plugins/data/public/search/aggs/param_types/index.ts +++ b/src/plugins/data/public/search/aggs/param_types/index.ts @@ -20,7 +20,6 @@ export * from './agg'; export * from './base'; export * from './field'; -export * from './filter'; export * from './json'; export * from './optioned'; export * from './string'; diff --git a/src/plugins/data/public/search/aggs/param_types/optioned.ts b/src/plugins/data/public/search/aggs/param_types/optioned.ts index 9eb7ceda60711..45d0a65f69170 100644 --- a/src/plugins/data/public/search/aggs/param_types/optioned.ts +++ b/src/plugins/data/public/search/aggs/param_types/optioned.ts @@ -27,12 +27,6 @@ export interface OptionedValueProp { isCompatible: (agg: IAggConfig) => boolean; } -export interface OptionedParamEditorProps { - aggParam: { - options: T[]; - }; -} - export class OptionedParamType extends BaseParamType { options: OptionedValueProp[]; diff --git a/src/plugins/data/public/search/aggs/types.ts b/src/plugins/data/public/search/aggs/types.ts index 95a7a45013567..8ad264f59cc27 100644 --- a/src/plugins/data/public/search/aggs/types.ts +++ b/src/plugins/data/public/search/aggs/types.ts @@ -19,20 +19,24 @@ import { IndexPattern } from '../../index_patterns'; import { - AggConfig, AggConfigSerialized, AggConfigs, + AggParamsRange, + AggParamsIpRange, + AggParamsDateRange, + AggParamsFilter, + AggParamsFilters, + AggParamsSignificantTerms, + AggParamsGeoTile, + AggParamsGeoHash, AggParamsTerms, - AggType, - aggTypeFieldFilters, + AggParamsHistogram, + AggParamsDateHistogram, AggTypesRegistrySetup, AggTypesRegistryStart, CreateAggConfigParams, - FieldParamType, getCalculateAutoTimeExpression, - MetricAggType, - parentPipelineAggHelper, - siblingPipelineAggHelper, + BUCKET_TYPES, } from './'; export { IAggConfig, AggConfigSerialized } from './agg_config'; @@ -43,7 +47,7 @@ export { IFieldParamType } from './param_types'; export { IMetricAggType } from './metrics/metric_agg_type'; export { DateRangeKey } from './buckets/lib/date_range'; export { IpRangeKey } from './buckets/lib/ip_range'; -export { OptionedValueProp, OptionedParamEditorProps } from './param_types/optioned'; +export { OptionedValueProp } from './param_types/optioned'; /** @internal */ export interface SearchAggsSetup { @@ -51,17 +55,6 @@ export interface SearchAggsSetup { types: AggTypesRegistrySetup; } -/** @internal */ -export interface SearchAggsStartLegacy { - AggConfig: typeof AggConfig; - AggType: typeof AggType; - aggTypeFieldFilters: typeof aggTypeFieldFilters; - FieldParamType: typeof FieldParamType; - MetricAggType: typeof MetricAggType; - parentPipelineAggHelper: typeof parentPipelineAggHelper; - siblingPipelineAggHelper: typeof siblingPipelineAggHelper; -} - /** @internal */ export interface SearchAggsStart { calculateAutoTimeExpression: ReturnType; @@ -73,6 +66,12 @@ export interface SearchAggsStart { types: AggTypesRegistryStart; } +/** @internal */ +export interface BaseAggParams { + json?: string; + customLabel?: string; +} + /** @internal */ export interface AggExpressionType { type: 'agg_type'; @@ -92,5 +91,15 @@ export type AggExpressionFunctionArgs< * @internal */ export interface AggParamsMapping { - terms: AggParamsTerms; + [BUCKET_TYPES.RANGE]: AggParamsRange; + [BUCKET_TYPES.IP_RANGE]: AggParamsIpRange; + [BUCKET_TYPES.DATE_RANGE]: AggParamsDateRange; + [BUCKET_TYPES.FILTER]: AggParamsFilter; + [BUCKET_TYPES.FILTERS]: AggParamsFilters; + [BUCKET_TYPES.SIGNIFICANT_TERMS]: AggParamsSignificantTerms; + [BUCKET_TYPES.GEOTILE_GRID]: AggParamsGeoTile; + [BUCKET_TYPES.GEOHASH_GRID]: AggParamsGeoHash; + [BUCKET_TYPES.HISTOGRAM]: AggParamsHistogram; + [BUCKET_TYPES.DATE_HISTOGRAM]: AggParamsDateHistogram; + [BUCKET_TYPES.TERMS]: AggParamsTerms; } diff --git a/src/plugins/data/public/search/aggs/utils/get_parsed_value.ts b/src/plugins/data/public/search/aggs/utils/get_parsed_value.ts new file mode 100644 index 0000000000000..48e752369d1d3 --- /dev/null +++ b/src/plugins/data/public/search/aggs/utils/get_parsed_value.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +/** + * This method parses a JSON string and constructs the Object or object described by the string. + * If the given string is not valid JSON, you will get a syntax error. + * @param data { Object } - an object that contains the required for parsing field + * @param key { string} - name of the field to be parsed + * + * @internal + */ +export const getParsedValue = (data: any, key: string) => { + try { + return data[key] ? JSON.parse(data[key]) : undefined; + } catch (e) { + throw new Error(`Unable to parse ${key} argument string`); + } +}; diff --git a/src/plugins/data/public/search/aggs/utils/index.ts b/src/plugins/data/public/search/aggs/utils/index.ts index 23606bd109342..169d872b17d3a 100644 --- a/src/plugins/data/public/search/aggs/utils/index.ts +++ b/src/plugins/data/public/search/aggs/utils/index.ts @@ -18,4 +18,5 @@ */ export * from './calculate_auto_time_expression'; +export * from './prop_filter'; export * from './to_angular_json'; diff --git a/src/plugins/data/public/search/aggs/filter/prop_filter.test.ts b/src/plugins/data/public/search/aggs/utils/prop_filter.test.ts similarity index 100% rename from src/plugins/data/public/search/aggs/filter/prop_filter.test.ts rename to src/plugins/data/public/search/aggs/utils/prop_filter.test.ts diff --git a/src/plugins/data/public/search/aggs/filter/prop_filter.ts b/src/plugins/data/public/search/aggs/utils/prop_filter.ts similarity index 97% rename from src/plugins/data/public/search/aggs/filter/prop_filter.ts rename to src/plugins/data/public/search/aggs/utils/prop_filter.ts index e6b5f3831e65d..01e98a68d3949 100644 --- a/src/plugins/data/public/search/aggs/filter/prop_filter.ts +++ b/src/plugins/data/public/search/aggs/utils/prop_filter.ts @@ -28,7 +28,7 @@ type FilterFunc

= (item: T[P]) => boolean; * * @returns the filter function which can be registered with angular */ -function propFilter

(prop: P) { +export function propFilter

(prop: P) { /** * List filtering function which accepts an array or list of values that a property * must contain @@ -92,5 +92,3 @@ function propFilter

(prop: P) { }); }; } - -export { propFilter }; diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index dd196074c8173..44082040b5b0b 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -18,7 +18,6 @@ */ import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks'; -import { AggTypeFieldFilters } from './aggs/param_types/filter'; import { ISearchStart } from './types'; import { searchSourceMock, createSearchSourceMock } from './search_source/mocks'; @@ -34,13 +33,6 @@ const searchStartMock: jest.Mocked = { search: jest.fn(), searchSource: searchSourceMock, __LEGACY: { - AggConfig: jest.fn() as any, - AggType: jest.fn(), - aggTypeFieldFilters: new AggTypeFieldFilters(), - FieldParamType: jest.fn(), - MetricAggType: jest.fn(), - parentPipelineAggHelper: jest.fn() as any, - siblingPipelineAggHelper: jest.fn() as any, esClient: { search: jest.fn(), msearch: jest.fn(), diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index b59524baa9fa7..4d183797dfef0 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -39,16 +39,9 @@ import { SearchInterceptor } from './search_interceptor'; import { getAggTypes, getAggTypesFunctions, - AggType, AggTypesRegistry, - AggConfig, AggConfigs, - FieldParamType, getCalculateAutoTimeExpression, - MetricAggType, - aggTypeFieldFilters, - parentPipelineAggHelper, - siblingPipelineAggHelper, } from './aggs'; import { FieldFormatsStart } from '../field_formats'; import { ISearchGeneric } from './i_search'; @@ -156,13 +149,6 @@ export class SearchService implements Plugin { const legacySearch = { esClient: this.esClient!, - AggConfig, - AggType, - aggTypeFieldFilters, - FieldParamType, - MetricAggType, - parentPipelineAggHelper, - siblingPipelineAggHelper, }; const searchSourceDependencies: SearchSourceDependencies = { diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 99d111ce1574e..1687c8f983393 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -18,7 +18,7 @@ */ import { CoreStart, SavedObjectReference } from 'kibana/public'; -import { SearchAggsSetup, SearchAggsStart, SearchAggsStartLegacy } from './aggs'; +import { SearchAggsSetup, SearchAggsStart } from './aggs'; import { ISearch, ISearchGeneric } from './i_search'; import { TStrategyTypes } from './strategy_types'; import { LegacyApiCaller } from './legacy/es_client'; @@ -88,5 +88,5 @@ export interface ISearchStart { references: SavedObjectReference[] ) => Promise; }; - __LEGACY: ISearchStartLegacy & SearchAggsStartLegacy; + __LEGACY: ISearchStartLegacy; } diff --git a/src/plugins/dev_tools/public/application.tsx b/src/plugins/dev_tools/public/application.tsx index 2c21b451cb9f7..5ec5b8036e4fb 100644 --- a/src/plugins/dev_tools/public/application.tsx +++ b/src/plugins/dev_tools/public/application.tsx @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiTab, EuiTabs, EuiToolTip } from '@elastic/eui'; @@ -26,17 +25,17 @@ import ReactDOM from 'react-dom'; import { useEffect, useRef } from 'react'; import { AppMountContext, AppMountDeprecated } from 'kibana/public'; -import { DevTool } from './plugin'; +import { DevToolApp } from './dev_tool'; interface DevToolsWrapperProps { - devTools: readonly DevTool[]; - activeDevTool: DevTool; + devTools: readonly DevToolApp[]; + activeDevTool: DevToolApp; appMountContext: AppMountContext; updateRoute: (newRoute: string) => void; } interface MountedDevToolDescriptor { - devTool: DevTool; + devTool: DevToolApp; mountpoint: HTMLElement; unmountHandler: () => void; } @@ -64,10 +63,10 @@ function DevToolsWrapper({ {devTools.map(currentDevTool => ( { - if (!currentDevTool.disabled) { + if (!currentDevTool.isDisabled()) { updateRoute(`/dev_tools/${currentDevTool.id}`); } }} @@ -151,7 +150,7 @@ export function renderApp( element: HTMLElement, appMountContext: AppMountContext, basePath: string, - devTools: readonly DevTool[] + devTools: readonly DevToolApp[] ) { if (redirectOnMissingCapabilities(appMountContext)) { return () => {}; @@ -162,21 +161,24 @@ export function renderApp( - {devTools.map(devTool => ( - ( - - )} - /> - ))} + {devTools + // Only create routes for devtools that are not disabled + .filter(devTool => !devTool.isDisabled()) + .map(devTool => ( + ( + + )} + /> + ))} diff --git a/src/plugins/dev_tools/public/dev_tool.ts b/src/plugins/dev_tools/public/dev_tool.ts new file mode 100644 index 0000000000000..943cca286a722 --- /dev/null +++ b/src/plugins/dev_tools/public/dev_tool.ts @@ -0,0 +1,106 @@ +/* + * 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 { App } from 'kibana/public'; + +/** + * Descriptor for a dev tool. A dev tool works similar to an application + * registered in the core application service. + */ +export type CreateDevToolArgs = Omit & { + disabled?: boolean; +}; + +export class DevToolApp { + /** + * The id of the dev tools. This will become part of the URL path + * (`dev_tools/${devTool.id}`. It has to be unique among registered + * dev tools. + */ + public readonly id: string; + /** + * The human readable name of the dev tool. Should be internationalized. + * This will be used as a label in the tab above the actual tool. + */ + public readonly title: string; + public readonly mount: App['mount']; + + /** + * Flag indicating to disable the tab of this dev tool. Navigating to a + * disabled dev tool will be treated as the navigation to an unknown route + * (redirect to the console). + */ + private disabled: boolean; + + /** + * Optional tooltip content of the tab. + */ + public readonly tooltipContent?: string; + /** + * Flag indicating whether the dev tool will do routing within the `dev_tools/${devTool.id}/` + * prefix. If it is set to true, the dev tool is responsible to redirect + * the user when navigating to unknown URLs within the prefix. If set + * to false only the root URL of the dev tool will be recognized as valid. + */ + public readonly enableRouting: boolean; + /** + * Number used to order the tabs. + */ + public readonly order: number; + + constructor( + id: string, + title: string, + mount: App['mount'], + enableRouting: boolean, + order: number, + toolTipContent = '', + disabled = false + ) { + this.id = id; + this.title = title; + this.mount = mount; + this.enableRouting = enableRouting; + this.order = order; + this.tooltipContent = toolTipContent; + this.disabled = disabled; + } + + public enable() { + this.disabled = false; + } + + public disable() { + this.disabled = true; + } + + public isDisabled(): boolean { + return this.disabled; + } +} + +export const createDevToolApp = ({ + id, + title, + mount, + enableRouting, + order, + tooltipContent, + disabled, +}: CreateDevToolArgs) => + new DevToolApp(id, title, mount, enableRouting, order, tooltipContent, disabled); diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index df61271baf879..bedf92818315a 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -17,9 +17,10 @@ * under the License. */ -import { App, CoreSetup, Plugin } from 'kibana/public'; +import { CoreSetup, Plugin } from 'kibana/public'; import { sortBy } from 'lodash'; import { KibanaLegacySetup } from '../../kibana_legacy/public'; +import { CreateDevToolArgs, DevToolApp, createDevToolApp } from './dev_tool'; import './index.scss'; @@ -34,7 +35,7 @@ export interface DevToolsSetup { * to switch between the tools. * @param devTool The dev tools descriptor */ - register: (devTool: DevTool) => void; + register: (devTool: CreateDevToolArgs) => DevToolApp; } export interface DevToolsStart { @@ -46,53 +47,13 @@ export interface DevToolsStart { * becomes an implementation detail. * @deprecated */ - getSortedDevTools: () => readonly DevTool[]; -} - -/** - * Descriptor for a dev tool. A dev tool works similar to an application - * registered in the core application service. - */ -export interface DevTool { - /** - * The id of the dev tools. This will become part of the URL path - * (`dev_tools/${devTool.id}`. It has to be unique among registered - * dev tools. - */ - id: string; - /** - * The human readable name of the dev tool. Should be internationalized. - * This will be used as a label in the tab above the actual tool. - */ - title: string; - mount: App['mount']; - /** - * Flag indicating to disable the tab of this dev tool. Navigating to a - * disabled dev tool will be treated as the navigation to an unknown route - * (redirect to the console). - */ - disabled?: boolean; - /** - * Optional tooltip content of the tab. - */ - tooltipContent?: string; - /** - * Flag indicating whether the dev tool will do routing within the `dev_tools/${devTool.id}/` - * prefix. If it is set to true, the dev tool is responsible to redirect - * the user when navigating to unknown URLs within the prefix. If set - * to false only the root URL of the dev tool will be recognized as valid. - */ - enableRouting: boolean; - /** - * Number used to order the tabs. - */ - order: number; + getSortedDevTools: () => readonly DevToolApp[]; } export class DevToolsPlugin implements Plugin { - private readonly devTools = new Map(); + private readonly devTools = new Map(); - private getSortedDevTools(): readonly DevTool[] { + private getSortedDevTools(): readonly DevToolApp[] { return sortBy([...this.devTools.values()], 'order'); } @@ -115,14 +76,16 @@ export class DevToolsPlugin implements Plugin { }); return { - register: (devTool: DevTool) => { - if (this.devTools.has(devTool.id)) { + register: (devToolArgs: CreateDevToolArgs) => { + if (this.devTools.has(devToolArgs.id)) { throw new Error( - `Dev tool with id [${devTool.id}] has already been registered. Use a unique id.` + `Dev tool with id [${devToolArgs.id}] has already been registered. Use a unique id.` ); } + const devTool = createDevToolApp(devToolArgs); this.devTools.set(devTool.id, devTool); + return devTool; }, }; } diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json index 2d41293f26369..0b3a07e98624e 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -2,5 +2,16 @@ "id": "discover", "version": "kibana", "server": true, - "ui": true + "ui": true, + "requiredPlugins": [ + "charts", + "data", + "embeddable", + "inspector", + "kibanaLegacy", + "navigation", + "uiActions", + "visualizations" + ], + "optionalPlugins": ["home", "share"] } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/_discover.scss b/src/plugins/discover/public/application/_discover.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/_discover.scss rename to src/plugins/discover/public/application/_discover.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/_hacks.scss b/src/plugins/discover/public/application/_hacks.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/_hacks.scss rename to src/plugins/discover/public/application/_hacks.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/_mixins.scss b/src/plugins/discover/public/application/_mixins.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/_mixins.scss rename to src/plugins/discover/public/application/_mixins.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/_index.scss b/src/plugins/discover/public/application/angular/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/_index.scss rename to src/plugins/discover/public/application/angular/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.html b/src/plugins/discover/public/application/angular/context.html similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.html rename to src/plugins/discover/public/application/angular/context.html diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js b/src/plugins/discover/public/application/angular/context.js similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js rename to src/plugins/discover/public/application/angular/context.js index 032ec7af09a30..33bbc8cb2e6cf 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context.js +++ b/src/plugins/discover/public/application/angular/context.js @@ -32,7 +32,7 @@ const k7Breadcrumbs = $route => { return [ ...getRootBreadcrumbs(), { - text: i18n.translate('kbn.context.breadcrumb', { + text: i18n.translate('discover.context.breadcrumb', { defaultMessage: 'Context of {indexPatternTitle}#{docId}', values: { indexPatternTitle: indexPattern.title, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/NOTES.md b/src/plugins/discover/public/application/angular/context/NOTES.md similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/NOTES.md rename to src/plugins/discover/public/application/angular/context/NOTES.md diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/_index.scss b/src/plugins/discover/public/application/angular/context/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/_index.scss rename to src/plugins/discover/public/application/angular/context/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/_stubs.js b/src/plugins/discover/public/application/angular/context/api/_stubs.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/_stubs.js rename to src/plugins/discover/public/application/angular/context/api/_stubs.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/anchor.js b/src/plugins/discover/public/application/angular/context/api/anchor.js similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/anchor.js rename to src/plugins/discover/public/application/angular/context/api/anchor.js index 4338d3e1dbdbd..4df5ba989f798 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/anchor.js +++ b/src/plugins/discover/public/application/angular/context/api/anchor.js @@ -46,7 +46,7 @@ export function fetchAnchorProvider(indexPatterns, searchSource) { if (_.get(response, ['hits', 'total'], 0) < 1) { throw new Error( - i18n.translate('kbn.context.failedToLoadAnchorDocumentErrorDescription', { + i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', { defaultMessage: 'Failed to load anchor document.', }) ); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/anchor.test.js b/src/plugins/discover/public/application/angular/context/api/anchor.test.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/anchor.test.js rename to src/plugins/discover/public/application/angular/context/api/anchor.test.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.predecessors.test.js b/src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.predecessors.test.js rename to src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.successors.test.js b/src/plugins/discover/public/application/angular/context/api/context.successors.test.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.successors.test.js rename to src/plugins/discover/public/application/angular/context/api/context.successors.test.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts b/src/plugins/discover/public/application/angular/context/api/context.ts similarity index 97% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts rename to src/plugins/discover/public/application/angular/context/api/context.ts index 2760eec38755e..0bca820f9a723 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/context.ts +++ b/src/plugins/discover/public/application/angular/context/api/context.ts @@ -17,17 +17,13 @@ * under the License. */ +import { Filter, IndexPatternsContract, IndexPattern } from 'src/plugins/data/public'; import { reverseSortDir, SortDirection } from './utils/sorting'; import { extractNanos, convertIsoToMillis } from './utils/date_conversion'; import { fetchHitsInInterval } from './utils/fetch_hits_in_interval'; import { generateIntervals } from './utils/generate_intervals'; import { getEsQuerySearchAfter } from './utils/get_es_query_search_after'; import { getEsQuerySort } from './utils/get_es_query_sort'; -import { - Filter, - IndexPatternsContract, - IndexPattern, -} from '../../../../../../../../../plugins/data/public'; import { getServices } from '../../../../kibana_services'; export type SurrDocType = 'successors' | 'predecessors'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/__tests__/date_conversion.test.ts b/src/plugins/discover/public/application/angular/context/api/utils/date_conversion.test.ts similarity index 96% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/__tests__/date_conversion.test.ts rename to src/plugins/discover/public/application/angular/context/api/utils/date_conversion.test.ts index b9ec105cc0e7b..223b174718296 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/__tests__/date_conversion.test.ts +++ b/src/plugins/discover/public/application/angular/context/api/utils/date_conversion.test.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { extractNanos } from '../date_conversion'; +import { extractNanos } from './date_conversion'; describe('function extractNanos', function() { test('extract nanos of 2014-01-01', function() { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/date_conversion.ts b/src/plugins/discover/public/application/angular/context/api/utils/date_conversion.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/date_conversion.ts rename to src/plugins/discover/public/application/angular/context/api/utils/date_conversion.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/fetch_hits_in_interval.ts b/src/plugins/discover/public/application/angular/context/api/utils/fetch_hits_in_interval.ts similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/fetch_hits_in_interval.ts rename to src/plugins/discover/public/application/angular/context/api/utils/fetch_hits_in_interval.ts index 8eed5d33ab004..437898201863f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/fetch_hits_in_interval.ts +++ b/src/plugins/discover/public/application/angular/context/api/utils/fetch_hits_in_interval.ts @@ -16,11 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { - ISearchSource, - EsQuerySortValue, - SortDirection, -} from '../../../../../../../../../../plugins/data/public'; +import { ISearchSource, EsQuerySortValue, SortDirection } from '../../../../../../../data/public'; import { convertTimeValueToIso } from './date_conversion'; import { EsHitRecordList } from '../context'; import { IntervalValue } from './generate_intervals'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/generate_intervals.ts b/src/plugins/discover/public/application/angular/context/api/utils/generate_intervals.ts similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/generate_intervals.ts rename to src/plugins/discover/public/application/angular/context/api/utils/generate_intervals.ts index b14180d32b4f2..1497f54aa5079 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/generate_intervals.ts +++ b/src/plugins/discover/public/application/angular/context/api/utils/generate_intervals.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { SortDirection } from '../../../../../../../../../../plugins/data/public'; +import { SortDirection } from '../../../../../../../data/public'; export type IntervalValue = number | null; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/get_es_query_search_after.ts b/src/plugins/discover/public/application/angular/context/api/utils/get_es_query_search_after.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/get_es_query_search_after.ts rename to src/plugins/discover/public/application/angular/context/api/utils/get_es_query_search_after.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/get_es_query_sort.ts b/src/plugins/discover/public/application/angular/context/api/utils/get_es_query_sort.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/get_es_query_sort.ts rename to src/plugins/discover/public/application/angular/context/api/utils/get_es_query_sort.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/__tests__/sorting.test.ts b/src/plugins/discover/public/application/angular/context/api/utils/sorting.test.ts similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/__tests__/sorting.test.ts rename to src/plugins/discover/public/application/angular/context/api/utils/sorting.test.ts index eeae2aa2c5d0a..350a0a8ede8d5 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/__tests__/sorting.test.ts +++ b/src/plugins/discover/public/application/angular/context/api/utils/sorting.test.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { reverseSortDir, SortDirection } from '../sorting'; +import { reverseSortDir, SortDirection } from './sorting'; describe('function reverseSortDir', function() { test('reverse a given sort direction', function() { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/sorting.ts b/src/plugins/discover/public/application/angular/context/api/utils/sorting.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/api/utils/sorting.ts rename to src/plugins/discover/public/application/angular/context/api/utils/sorting.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/_action_bar.scss b/src/plugins/discover/public/application/angular/context/components/action_bar/_action_bar.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/_action_bar.scss rename to src/plugins/discover/public/application/angular/context/components/action_bar/_action_bar.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/_index.scss b/src/plugins/discover/public/application/angular/context/components/action_bar/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/_index.scss rename to src/plugins/discover/public/application/angular/context/components/action_bar/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar.test.tsx b/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar.test.tsx rename to src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar.tsx b/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.tsx similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar.tsx rename to src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.tsx index 8fcfcba08955c..97a29ab21c581 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar.tsx +++ b/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.tsx @@ -111,7 +111,7 @@ export function ActionBar({ }} flush="right" > - + @@ -119,10 +119,10 @@ export function ActionBar({ {isSuccessor ? ( ) : ( )} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar_directive.ts b/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_directive.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar_directive.ts rename to src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_directive.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar_warning.tsx b/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_warning.tsx similarity index 90% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar_warning.tsx rename to src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_warning.tsx index 6b922bb05a243..db757881ad819 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/action_bar_warning.tsx +++ b/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_warning.tsx @@ -31,12 +31,12 @@ export function ActionBarWarning({ docCount, type }: { docCount: number; type: S title={ docCount === 0 ? ( ) : ( @@ -55,12 +55,12 @@ export function ActionBarWarning({ docCount, type }: { docCount: number; type: S title={ docCount === 0 ? ( ) : ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/index.ts b/src/plugins/discover/public/application/angular/context/components/action_bar/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/components/action_bar/index.ts rename to src/plugins/discover/public/application/angular/context/components/action_bar/index.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/helpers/call_after_bindings_workaround.js b/src/plugins/discover/public/application/angular/context/helpers/call_after_bindings_workaround.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/helpers/call_after_bindings_workaround.js rename to src/plugins/discover/public/application/angular/context/helpers/call_after_bindings_workaround.js diff --git a/src/plugins/discover/public/application/angular/context/query/actions.js b/src/plugins/discover/public/application/angular/context/query/actions.js new file mode 100644 index 0000000000000..1b1fa7138bfda --- /dev/null +++ b/src/plugins/discover/public/application/angular/context/query/actions.js @@ -0,0 +1,192 @@ +/* + * 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 _ from 'lodash'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { getServices } from '../../../../kibana_services'; + +import { fetchAnchorProvider } from '../api/anchor'; +import { fetchContextProvider } from '../api/context'; +import { getQueryParameterActions } from '../query_parameters'; +import { FAILURE_REASONS, LOADING_STATUS } from './constants'; +import { MarkdownSimple } from '../../../../../../kibana_react/public'; + +export function QueryActionsProvider(Promise) { + const { filterManager, indexPatterns, data } = getServices(); + const fetchAnchor = fetchAnchorProvider(indexPatterns, data.search.searchSource.create()); + const { fetchSurroundingDocs } = fetchContextProvider(indexPatterns); + const { setPredecessorCount, setQueryParameters, setSuccessorCount } = getQueryParameterActions( + filterManager, + indexPatterns + ); + + const setFailedStatus = state => (subject, details = {}) => + (state.loadingStatus[subject] = { + status: LOADING_STATUS.FAILED, + reason: FAILURE_REASONS.UNKNOWN, + ...details, + }); + + const setLoadedStatus = state => subject => + (state.loadingStatus[subject] = { + status: LOADING_STATUS.LOADED, + }); + + const setLoadingStatus = state => subject => + (state.loadingStatus[subject] = { + status: LOADING_STATUS.LOADING, + }); + + const fetchAnchorRow = state => () => { + const { + queryParameters: { indexPatternId, anchorId, sort, tieBreakerField }, + } = state; + + if (!tieBreakerField) { + return Promise.reject( + setFailedStatus(state)('anchor', { + reason: FAILURE_REASONS.INVALID_TIEBREAKER, + }) + ); + } + + setLoadingStatus(state)('anchor'); + + return Promise.try(() => + fetchAnchor(indexPatternId, anchorId, [_.zipObject([sort]), { [tieBreakerField]: sort[1] }]) + ).then( + anchorDocument => { + setLoadedStatus(state)('anchor'); + state.rows.anchor = anchorDocument; + return anchorDocument; + }, + error => { + setFailedStatus(state)('anchor', { error }); + getServices().toastNotifications.addDanger({ + title: i18n.translate('discover.context.unableToLoadAnchorDocumentDescription', { + defaultMessage: 'Unable to load the anchor document', + }), + text: {error.message}, + }); + throw error; + } + ); + }; + + const fetchSurroundingRows = (type, state) => { + const { + queryParameters: { indexPatternId, sort, tieBreakerField }, + rows: { anchor }, + } = state; + const filters = getServices().filterManager.getFilters(); + + const count = + type === 'successors' + ? state.queryParameters.successorCount + : state.queryParameters.predecessorCount; + + if (!tieBreakerField) { + return Promise.reject( + setFailedStatus(state)(type, { + reason: FAILURE_REASONS.INVALID_TIEBREAKER, + }) + ); + } + + setLoadingStatus(state)(type); + const [sortField, sortDir] = sort; + + return Promise.try(() => + fetchSurroundingDocs( + type, + indexPatternId, + anchor, + sortField, + tieBreakerField, + sortDir, + count, + filters + ) + ).then( + documents => { + setLoadedStatus(state)(type); + state.rows[type] = documents; + return documents; + }, + error => { + setFailedStatus(state)(type, { error }); + getServices().toastNotifications.addDanger({ + title: i18n.translate('discover.context.unableToLoadDocumentDescription', { + defaultMessage: 'Unable to load documents', + }), + text: {error.message}, + }); + throw error; + } + ); + }; + + const fetchContextRows = state => () => + Promise.all([ + fetchSurroundingRows('predecessors', state), + fetchSurroundingRows('successors', state), + ]); + + const fetchAllRows = state => () => + Promise.try(fetchAnchorRow(state)).then(fetchContextRows(state)); + + const fetchContextRowsWithNewQueryParameters = state => queryParameters => { + setQueryParameters(state)(queryParameters); + return fetchContextRows(state)(); + }; + + const fetchAllRowsWithNewQueryParameters = state => queryParameters => { + setQueryParameters(state)(queryParameters); + return fetchAllRows(state)(); + }; + + const fetchGivenPredecessorRows = state => count => { + setPredecessorCount(state)(count); + return fetchSurroundingRows('predecessors', state); + }; + + const fetchGivenSuccessorRows = state => count => { + setSuccessorCount(state)(count); + return fetchSurroundingRows('successors', state); + }; + + const setAllRows = state => (predecessorRows, anchorRow, successorRows) => + (state.rows.all = [ + ...(predecessorRows || []), + ...(anchorRow ? [anchorRow] : []), + ...(successorRows || []), + ]); + + return { + fetchAllRows, + fetchAllRowsWithNewQueryParameters, + fetchAnchorRow, + fetchContextRows, + fetchContextRowsWithNewQueryParameters, + fetchGivenPredecessorRows, + fetchGivenSuccessorRows, + setAllRows, + }; +} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/constants.js b/src/plugins/discover/public/application/angular/context/query/constants.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/constants.js rename to src/plugins/discover/public/application/angular/context/query/constants.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/index.js b/src/plugins/discover/public/application/angular/context/query/index.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/index.js rename to src/plugins/discover/public/application/angular/context/query/index.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/state.js b/src/plugins/discover/public/application/angular/context/query/state.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/state.js rename to src/plugins/discover/public/application/angular/context/query/state.js diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/actions.js b/src/plugins/discover/public/application/angular/context/query_parameters/actions.js new file mode 100644 index 0000000000000..4f86dea08fe84 --- /dev/null +++ b/src/plugins/discover/public/application/angular/context/query_parameters/actions.js @@ -0,0 +1,74 @@ +/* + * 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 _ from 'lodash'; +import { esFilters } from '../../../../../../data/public'; + +import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE, QUERY_PARAMETER_KEYS } from './constants'; + +export function getQueryParameterActions(filterManager, indexPatterns) { + const setPredecessorCount = state => predecessorCount => + (state.queryParameters.predecessorCount = clamp( + MIN_CONTEXT_SIZE, + MAX_CONTEXT_SIZE, + predecessorCount + )); + + const setSuccessorCount = state => successorCount => + (state.queryParameters.successorCount = clamp( + MIN_CONTEXT_SIZE, + MAX_CONTEXT_SIZE, + successorCount + )); + + const setQueryParameters = state => queryParameters => + Object.assign(state.queryParameters, _.pick(queryParameters, QUERY_PARAMETER_KEYS)); + + const updateFilters = () => filters => { + filterManager.setFilters(filters); + }; + + const addFilter = state => async (field, values, operation) => { + const indexPatternId = state.queryParameters.indexPatternId; + const newFilters = esFilters.generateFilters( + filterManager, + field, + values, + operation, + indexPatternId + ); + filterManager.addFilters(newFilters); + if (indexPatterns) { + const indexPattern = await indexPatterns.get(indexPatternId); + indexPattern.popularizeField(field.name, 1); + } + }; + + return { + addFilter, + updateFilters, + setPredecessorCount, + setQueryParameters, + setSuccessorCount, + }; +} + +function clamp(minimum, maximum, value) { + return Math.max(Math.min(maximum, value), minimum); +} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/actions.test.ts b/src/plugins/discover/public/application/angular/context/query_parameters/actions.test.ts similarity index 97% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/actions.test.ts rename to src/plugins/discover/public/application/angular/context/query_parameters/actions.test.ts index 35fbd33fb4bc9..00747fcc81227 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/actions.test.ts +++ b/src/plugins/discover/public/application/angular/context/query_parameters/actions.test.ts @@ -19,8 +19,8 @@ // @ts-ignore import { getQueryParameterActions } from './actions'; -import { FilterManager } from '../../../../../../../../../plugins/data/public'; -import { coreMock } from '../../../../../../../../../core/public/mocks'; +import { FilterManager } from '../../../../../../data/public'; +import { coreMock } from '../../../../../../../core/public/mocks'; const setupMock = coreMock.createSetup(); let state: { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/constants.ts b/src/plugins/discover/public/application/angular/context/query_parameters/constants.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/constants.ts rename to src/plugins/discover/public/application/angular/context/query_parameters/constants.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/index.js b/src/plugins/discover/public/application/angular/context/query_parameters/index.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/index.js rename to src/plugins/discover/public/application/angular/context/query_parameters/index.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/state.ts b/src/plugins/discover/public/application/angular/context/query_parameters/state.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query_parameters/state.ts rename to src/plugins/discover/public/application/angular/context/query_parameters/state.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.html b/src/plugins/discover/public/application/angular/context_app.html similarity index 78% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.html rename to src/plugins/discover/public/application/angular/context_app.html index 8bbb746fa45f8..1f2d6a073150b 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.html +++ b/src/plugins/discover/public/application/angular/context_app.html @@ -21,7 +21,7 @@

@@ -30,7 +30,7 @@
@@ -92,7 +92,7 @@ >
diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.js b/src/plugins/discover/public/application/angular/context_app.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_app.js rename to src/plugins/discover/public/application/angular/context_app.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.test.ts b/src/plugins/discover/public/application/angular/context_state.test.ts similarity index 97% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.test.ts rename to src/plugins/discover/public/application/angular/context_state.test.ts index 1fa71ed11643a..83bf1b1d7e3d5 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.test.ts +++ b/src/plugins/discover/public/application/angular/context_state.test.ts @@ -19,8 +19,8 @@ import { getState } from './context_state'; import { createBrowserHistory, History } from 'history'; -import { FilterManager, Filter } from '../../../../../../../plugins/data/public'; -import { coreMock } from '../../../../../../../core/public/mocks'; +import { FilterManager, Filter } from '../../../../data/public'; +import { coreMock } from '../../../../../core/public/mocks'; const setupMock = coreMock.createSetup(); describe('Test Discover Context State', () => { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts b/src/plugins/discover/public/application/angular/context_state.ts similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts rename to src/plugins/discover/public/application/angular/context_state.ts index b46995d44d826..7a92a6ace125b 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context_state.ts +++ b/src/plugins/discover/public/application/angular/context_state.ts @@ -23,8 +23,8 @@ import { createKbnUrlStateStorage, syncStates, BaseStateContainer, -} from '../../../../../../../plugins/kibana_utils/public'; -import { esFilters, FilterManager, Filter, Query } from '../../../../../../../plugins/data/public'; +} from '../../../../kibana_utils/public'; +import { esFilters, FilterManager, Filter, Query } from '../../../../data/public'; export interface AppState { /** diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/__snapshots__/no_results.test.js.snap b/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/__snapshots__/no_results.test.js.snap rename to src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/_histogram.scss b/src/plugins/discover/public/application/angular/directives/_histogram.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/_histogram.scss rename to src/plugins/discover/public/application/angular/directives/_histogram.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/_index.scss b/src/plugins/discover/public/application/angular/directives/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/_index.scss rename to src/plugins/discover/public/application/angular/directives/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/_no_results.scss b/src/plugins/discover/public/application/angular/directives/_no_results.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/_no_results.scss rename to src/plugins/discover/public/application/angular/directives/_no_results.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/collapsible_sidebar/_collapsible_sidebar.scss b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_collapsible_sidebar.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/collapsible_sidebar/_collapsible_sidebar.scss rename to src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_collapsible_sidebar.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/collapsible_sidebar/_depth.scss b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_depth.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/collapsible_sidebar/_depth.scss rename to src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_depth.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/collapsible_sidebar/_index.scss b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/collapsible_sidebar/_index.scss rename to src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/collapsible_sidebar/collapsible_sidebar.ts b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/collapsible_sidebar.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/collapsible_sidebar/collapsible_sidebar.ts rename to src/plugins/discover/public/application/angular/directives/collapsible_sidebar/collapsible_sidebar.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/debounce/debounce.js b/src/plugins/discover/public/application/angular/directives/debounce/debounce.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/debounce/debounce.js rename to src/plugins/discover/public/application/angular/directives/debounce/debounce.js diff --git a/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts b/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts new file mode 100644 index 0000000000000..bc08d8566d48a --- /dev/null +++ b/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts @@ -0,0 +1,181 @@ +/* + * 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 sinon, { SinonSpy } from 'sinon'; +import angular, { auto, ITimeoutService } from 'angular'; +import 'angular-mocks'; +import 'angular-sanitize'; +import 'angular-route'; + +// @ts-ignore +import { DebounceProvider } from './index'; +import { coreMock } from '../../../../../../../core/public/mocks'; +import { initializeInnerAngularModule } from '../../../../get_inner_angular'; +import { navigationPluginMock } from '../../../../../../navigation/public/mocks'; +import { dataPluginMock } from '../../../../../../data/public/mocks'; +import { initAngularBootstrap } from '../../../../../../kibana_legacy/public'; + +describe('debounce service', function() { + let debounce: (fn: () => void, timeout: number, options?: any) => any; + let debounceFromProvider: (fn: () => void, timeout: number, options?: any) => any; + let $timeout: ITimeoutService; + let spy: SinonSpy; + + beforeEach(() => { + spy = sinon.spy(); + + initAngularBootstrap(); + + initializeInnerAngularModule( + 'app/discover', + coreMock.createStart(), + navigationPluginMock.createStartContract(), + dataPluginMock.createStartContract() + ); + + angular.mock.module('app/discover'); + + angular.mock.inject( + ($injector: auto.IInjectorService, _$timeout_: ITimeoutService, Private: any) => { + $timeout = _$timeout_; + + debounce = $injector.get('debounce'); + debounceFromProvider = Private(DebounceProvider); + } + ); + }); + + it('should have a cancel method', function() { + const bouncer = debounce(() => {}, 100); + const bouncerFromProvider = debounceFromProvider(() => {}, 100); + + expect(bouncer).toHaveProperty('cancel'); + expect(bouncerFromProvider).toHaveProperty('cancel'); + }); + + describe('delayed execution', function() { + const sandbox = sinon.createSandbox(); + + beforeEach(() => sandbox.useFakeTimers()); + afterEach(() => sandbox.restore()); + + it('should delay execution', function() { + const bouncer = debounce(spy, 100); + const bouncerFromProvider = debounceFromProvider(spy, 100); + + bouncer(); + sinon.assert.notCalled(spy); + $timeout.flush(); + sinon.assert.calledOnce(spy); + + spy.resetHistory(); + + bouncerFromProvider(); + sinon.assert.notCalled(spy); + $timeout.flush(); + sinon.assert.calledOnce(spy); + }); + + it('should fire on leading edge', function() { + const bouncer = debounce(spy, 100, { leading: true }); + const bouncerFromProvider = debounceFromProvider(spy, 100, { leading: true }); + + bouncer(); + sinon.assert.calledOnce(spy); + $timeout.flush(); + sinon.assert.calledTwice(spy); + + spy.resetHistory(); + + bouncerFromProvider(); + sinon.assert.calledOnce(spy); + $timeout.flush(); + sinon.assert.calledTwice(spy); + }); + + it('should only fire on leading edge', function() { + const bouncer = debounce(spy, 100, { leading: true, trailing: false }); + const bouncerFromProvider = debounceFromProvider(spy, 100, { + leading: true, + trailing: false, + }); + + bouncer(); + sinon.assert.calledOnce(spy); + $timeout.flush(); + sinon.assert.calledOnce(spy); + + spy.resetHistory(); + + bouncerFromProvider(); + sinon.assert.calledOnce(spy); + $timeout.flush(); + sinon.assert.calledOnce(spy); + }); + + it('should reset delayed execution', function() { + const cancelSpy = sinon.spy($timeout, 'cancel'); + const bouncer = debounce(spy, 100); + const bouncerFromProvider = debounceFromProvider(spy, 100); + + bouncer(); + sandbox.clock.tick(1); + + bouncer(); + sinon.assert.notCalled(spy); + $timeout.flush(); + sinon.assert.calledOnce(spy); + sinon.assert.calledOnce(cancelSpy); + + spy.resetHistory(); + cancelSpy.resetHistory(); + + bouncerFromProvider(); + sandbox.clock.tick(1); + + bouncerFromProvider(); + sinon.assert.notCalled(spy); + $timeout.flush(); + sinon.assert.calledOnce(spy); + sinon.assert.calledOnce(cancelSpy); + }); + }); + + describe('cancel', function() { + it('should cancel the $timeout', function() { + const cancelSpy = sinon.spy($timeout, 'cancel'); + const bouncer = debounce(spy, 100); + const bouncerFromProvider = debounceFromProvider(spy, 100); + + bouncer(); + bouncer.cancel(); + sinon.assert.calledOnce(cancelSpy); + // throws if pending timeouts + $timeout.verifyNoPendingTasks(); + + cancelSpy.resetHistory(); + + bouncerFromProvider(); + bouncerFromProvider.cancel(); + sinon.assert.calledOnce(cancelSpy); + // throws if pending timeouts + $timeout.verifyNoPendingTasks(); + }); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/debounce/index.js b/src/plugins/discover/public/application/angular/directives/debounce/index.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/debounce/index.js rename to src/plugins/discover/public/application/angular/directives/debounce/index.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/fixed_scroll.js b/src/plugins/discover/public/application/angular/directives/fixed_scroll.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/fixed_scroll.js rename to src/plugins/discover/public/application/angular/directives/fixed_scroll.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx b/src/plugins/discover/public/application/angular/directives/histogram.tsx similarity index 97% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx rename to src/plugins/discover/public/application/angular/directives/histogram.tsx index 8c55622e4c604..d856be58958f1 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/histogram.tsx +++ b/src/plugins/discover/public/application/angular/directives/histogram.tsx @@ -39,6 +39,7 @@ import { TooltipType, ElementClickListener, XYChartElementEvent, + BrushEndListener, } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; @@ -143,13 +144,12 @@ export class DiscoverHistogram extends Component { - const range = { - from: min, - to: max, - }; - - this.props.timefilterUpdateHandler(range); + public onBrushEnd: BrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [from, to] = x; + this.props.timefilterUpdateHandler({ from, to }); }; public onElementClick = (xInterval: number): ElementClickListener => ([elementData]) => { @@ -175,7 +175,7 @@ export class DiscoverHistogram extends Component

@@ -114,14 +114,14 @@ export class DiscoverNoResults extends Component {

@@ -155,7 +155,7 @@ export class DiscoverNoResults extends Component { @@ -168,7 +168,7 @@ export class DiscoverNoResults extends Component { @@ -181,7 +181,7 @@ export class DiscoverNoResults extends Component { @@ -194,7 +194,7 @@ export class DiscoverNoResults extends Component { @@ -210,14 +210,14 @@ export class DiscoverNoResults extends Component {

@@ -256,7 +256,7 @@ export class DiscoverNoResults extends Component { } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/no_results.test.js b/src/plugins/discover/public/application/angular/directives/no_results.test.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/no_results.test.js rename to src/plugins/discover/public/application/angular/directives/no_results.test.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/render_complete.ts b/src/plugins/discover/public/application/angular/directives/render_complete.ts similarity index 92% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/render_complete.ts rename to src/plugins/discover/public/application/angular/directives/render_complete.ts index 7757deb806a18..635cf68f12fcb 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/render_complete.ts +++ b/src/plugins/discover/public/application/angular/directives/render_complete.ts @@ -17,7 +17,7 @@ * under the License. */ import { IScope } from 'angular'; -import { RenderCompleteHelper } from '../../../../../../../../plugins/kibana_utils/public'; +import { RenderCompleteHelper } from '../../../../../kibana_utils/public'; export function createRenderCompleteDirective() { return { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/uninitialized.tsx b/src/plugins/discover/public/application/angular/directives/uninitialized.tsx similarity index 92% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/uninitialized.tsx rename to src/plugins/discover/public/application/angular/directives/uninitialized.tsx index b308607bbfbb0..d04aea0933115 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/uninitialized.tsx +++ b/src/plugins/discover/public/application/angular/directives/uninitialized.tsx @@ -37,7 +37,7 @@ export const DiscoverUninitialized = ({ onRefresh }: Props) => { title={

@@ -45,7 +45,7 @@ export const DiscoverUninitialized = ({ onRefresh }: Props) => { body={

@@ -53,7 +53,7 @@ export const DiscoverUninitialized = ({ onRefresh }: Props) => { actions={ diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html b/src/plugins/discover/public/application/angular/discover.html similarity index 89% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html rename to src/plugins/discover/public/application/angular/discover.html index 1221b01657e45..b4db89b9275b4 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.html +++ b/src/plugins/discover/public/application/angular/discover.html @@ -61,7 +61,7 @@

{{screenTitle}}

@@ -83,7 +83,7 @@

{{screenTitle}}

@@ -93,7 +93,7 @@

{{screenTitle}}

{{(hits || 0) | number:0}} @@ -104,12 +104,12 @@

{{screenTitle}}

id="reload_saved_search" ng-click="resetQuery()" > - {{::'kbn.discover.reloadSavedSearchButton' | i18n: {defaultMessage: 'Reset search'} }} + {{::'discover.reloadSavedSearchButton' | i18n: {defaultMessage: 'Reset search'} }}
@@ -117,7 +117,7 @@

{{screenTitle}}

@@ -141,7 +141,7 @@

{{screenTitle}}

> {{screenTitle}} >

{{screenTitle}} class="dscTable__footer" > {{screenTitle}}
diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js rename to src/plugins/discover/public/application/angular/discover.js index c1de704d1c00a..2afd0322f8701 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -26,11 +26,8 @@ import dateMath from '@elastic/datemath'; import { i18n } from '@kbn/i18n'; import { getState, splitState } from './discover_state'; -import { RequestAdapter } from '../../../../../../../plugins/inspector/public'; -import { - SavedObjectSaveModal, - showSaveModal, -} from '../../../../../../../plugins/saved_objects/public'; +import { RequestAdapter } from '../../../../inspector/public'; +import { SavedObjectSaveModal, showSaveModal } from '../../../../saved_objects/public'; import { getSortArray, getSortForSearchSource } from './doc_table'; import * as columnActions from './doc_table/actions/columns'; @@ -75,9 +72,9 @@ import { syncQueryStateWithUrl, getDefaultQuery, search, -} from '../../../../../../../plugins/data/public'; +} from '../../../../data/public'; import { getIndexPatternId } from '../helpers/get_index_pattern_id'; -import { addFatalError } from '../../../../../../../plugins/kibana_legacy/public'; +import { addFatalError } from '../../../../kibana_legacy/public'; const fetchStatuses = { UNINITIALIZED: 'uninitialized', @@ -99,10 +96,10 @@ app.config($routeProvider => { } return { - text: i18n.translate('kbn.discover.badge.readOnly.text', { + text: i18n.translate('discover.badge.readOnly.text', { defaultMessage: 'Read only', }), - tooltip: i18n.translate('kbn.discover.badge.readOnly.tooltip', { + tooltip: i18n.translate('discover.badge.readOnly.tooltip', { defaultMessage: 'Unable to save searches', }), iconType: 'glasses', @@ -333,10 +330,10 @@ function discoverController( const getTopNavLinks = () => { const newSearch = { id: 'new', - label: i18n.translate('kbn.discover.localMenu.localMenu.newSearchTitle', { + label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { defaultMessage: 'New', }), - description: i18n.translate('kbn.discover.localMenu.newSearchDescription', { + description: i18n.translate('discover.localMenu.newSearchDescription', { defaultMessage: 'New Search', }), run: function() { @@ -349,10 +346,10 @@ function discoverController( const saveSearch = { id: 'save', - label: i18n.translate('kbn.discover.localMenu.saveTitle', { + label: i18n.translate('discover.localMenu.saveTitle', { defaultMessage: 'Save', }), - description: i18n.translate('kbn.discover.localMenu.saveSearchDescription', { + description: i18n.translate('discover.localMenu.saveSearchDescription', { defaultMessage: 'Save Search', }), testId: 'discoverSaveButton', @@ -389,7 +386,7 @@ function discoverController( title={savedSearch.title} showCopyOnSave={!!savedSearch.id} objectType="search" - description={i18n.translate('kbn.discover.localMenu.saveSaveSearchDescription', { + description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { defaultMessage: 'Save your Discover search so you can use it in visualizations and dashboards', })} @@ -402,10 +399,10 @@ function discoverController( const openSearch = { id: 'open', - label: i18n.translate('kbn.discover.localMenu.openTitle', { + label: i18n.translate('discover.localMenu.openTitle', { defaultMessage: 'Open', }), - description: i18n.translate('kbn.discover.localMenu.openSavedSearchDescription', { + description: i18n.translate('discover.localMenu.openSavedSearchDescription', { defaultMessage: 'Open Saved Search', }), testId: 'discoverOpenButton', @@ -419,10 +416,10 @@ function discoverController( const shareSearch = { id: 'share', - label: i18n.translate('kbn.discover.localMenu.shareTitle', { + label: i18n.translate('discover.localMenu.shareTitle', { defaultMessage: 'Share', }), - description: i18n.translate('kbn.discover.localMenu.shareSearchDescription', { + description: i18n.translate('discover.localMenu.shareSearchDescription', { defaultMessage: 'Share Search', }), testId: 'shareTopNavButton', @@ -446,10 +443,10 @@ function discoverController( const inspectSearch = { id: 'inspect', - label: i18n.translate('kbn.discover.localMenu.inspectTitle', { + label: i18n.translate('discover.localMenu.inspectTitle', { defaultMessage: 'Inspect', }), - description: i18n.translate('kbn.discover.localMenu.openInspectorForSearchDescription', { + description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', { defaultMessage: 'Open Inspector for search', }), testId: 'openInspectorButton', @@ -492,7 +489,7 @@ function discoverController( const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; chrome.docTitle.change(`Discover${pageTitleSuffix}`); - const discoverBreadcrumbsTitle = i18n.translate('kbn.discover.discoverBreadcrumbTitle', { + const discoverBreadcrumbsTitle = i18n.translate('discover.discoverBreadcrumbTitle', { defaultMessage: 'Discover', }); @@ -606,16 +603,16 @@ function discoverController( $scope.state.sort = getSortArray($scope.state.sort, $scope.indexPattern); $scope.getBucketIntervalToolTipText = () => { - return i18n.translate('kbn.discover.bucketIntervalTooltip', { + return i18n.translate('discover.bucketIntervalTooltip', { defaultMessage: 'This interval creates {bucketsDescription} to show in the selected time range, so it has been scaled to {bucketIntervalDescription}', values: { bucketsDescription: $scope.bucketInterval.scale > 1 - ? i18n.translate('kbn.discover.bucketIntervalTooltip.tooLargeBucketsText', { + ? i18n.translate('discover.bucketIntervalTooltip.tooLargeBucketsText', { defaultMessage: 'buckets that are too large', }) - : i18n.translate('kbn.discover.bucketIntervalTooltip.tooManyBucketsText', { + : i18n.translate('discover.bucketIntervalTooltip.tooManyBucketsText', { defaultMessage: 'too many buckets', }), bucketIntervalDescription: $scope.bucketInterval.description, @@ -748,7 +745,7 @@ function discoverController( $scope.$evalAsync(() => { if (id) { toastNotifications.addSuccess({ - title: i18n.translate('kbn.discover.notifications.savedSearchTitle', { + title: i18n.translate('discover.notifications.savedSearchTitle', { defaultMessage: `Search '{savedSearchTitle}' was saved`, values: { savedSearchTitle: savedSearch.title, @@ -769,7 +766,7 @@ function discoverController( return { id }; } catch (saveError) { toastNotifications.addDanger({ - title: i18n.translate('kbn.discover.notifications.notSavedSearchTitle', { + title: i18n.translate('discover.notifications.notSavedSearchTitle', { defaultMessage: `Search '{savedSearchTitle}' was not saved.`, values: { savedSearchTitle: savedSearch.title, @@ -812,7 +809,7 @@ function discoverController( $scope.fetchError = fetchError; } else { toastNotifications.addError(error, { - title: i18n.translate('kbn.discover.errorLoadingData', { + title: i18n.translate('discover.errorLoadingData', { defaultMessage: 'Error loading data', }), toastMessage: error.shortMessage || error.body?.message, @@ -903,10 +900,10 @@ function discoverController( function logInspectorRequest() { inspectorAdapters.requests.reset(); - const title = i18n.translate('kbn.discover.inspectorRequestDataTitle', { + const title = i18n.translate('discover.inspectorRequestDataTitle', { defaultMessage: 'data', }); - const description = i18n.translate('kbn.discover.inspectorRequestDescription', { + const description = i18n.translate('discover.inspectorRequestDescription', { defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', }); inspectorRequest = inspectorAdapters.requests.start(title, { description }); @@ -1049,7 +1046,7 @@ function discoverController( } function getIndexPatternWarning(index) { - return i18n.translate('kbn.discover.valueIsNotConfiguredIndexPatternIDWarningTitle', { + return i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', { defaultMessage: '{stateVal} is not a configured index pattern ID', values: { stateVal: `"${index}"`, @@ -1076,7 +1073,7 @@ function discoverController( if (ownIndexPattern) { toastNotifications.addWarning({ title: warningTitle, - text: i18n.translate('kbn.discover.showingSavedIndexPatternWarningDescription', { + text: i18n.translate('discover.showingSavedIndexPatternWarningDescription', { defaultMessage: 'Showing the saved index pattern: "{ownIndexPatternTitle}" ({ownIndexPatternId})', values: { @@ -1090,7 +1087,7 @@ function discoverController( toastNotifications.addWarning({ title: warningTitle, - text: i18n.translate('kbn.discover.showingDefaultIndexPatternWarningDescription', { + text: i18n.translate('discover.showingDefaultIndexPatternWarningDescription', { defaultMessage: 'Showing the default index pattern: "{loadedIndexPatternTitle}" ({loadedIndexPatternId})', values: { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover_state.test.ts b/src/plugins/discover/public/application/angular/discover_state.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover_state.test.ts rename to src/plugins/discover/public/application/angular/discover_state.test.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts similarity index 96% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover_state.ts rename to src/plugins/discover/public/application/angular/discover_state.ts index 2a036f0ac60ad..46500d9fdf85e 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -24,9 +24,9 @@ import { syncState, ReduxLikeStateContainer, IKbnUrlStateStorage, -} from '../../../../../../../plugins/kibana_utils/public'; -import { esFilters, Filter, Query } from '../../../../../../../plugins/data/public'; -import { migrateLegacyQuery } from '../../../../../../../plugins/kibana_legacy/public'; +} from '../../../../kibana_utils/public'; +import { esFilters, Filter, Query } from '../../../../data/public'; +import { migrateLegacyQuery } from '../../../../kibana_legacy/public'; export interface AppState { /** diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc.html b/src/plugins/discover/public/application/angular/doc.html similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc.html rename to src/plugins/discover/public/application/angular/doc.html diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc.ts b/src/plugins/discover/public/application/angular/doc.ts similarity index 91% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc.ts rename to src/plugins/discover/public/application/angular/doc.ts index 092e3c79b1007..2d0d45e5003fb 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc.ts +++ b/src/plugins/discover/public/application/angular/doc.ts @@ -49,7 +49,9 @@ app.config(($routeProvider: any) => { }) // the new route, es 7 deprecated types, es 8 removed them .when('/discover/doc/:indexPattern/:index', { - controller: ($scope: LazyScope, $route: any, es: any) => { + // have to be written as function expression, because it's not compiled in dev mode + // eslint-disable-next-line object-shorthand + controller: function($scope: LazyScope, $route: any, es: any) { timefilter.disableAutoRefreshSelector(); timefilter.disableTimeRangeSelector(); $scope.esClient = es; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/_doc_table.scss b/src/plugins/discover/public/application/angular/doc_table/_doc_table.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/_doc_table.scss rename to src/plugins/discover/public/application/angular/doc_table/_doc_table.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/_index.scss b/src/plugins/discover/public/application/angular/doc_table/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/_index.scss rename to src/plugins/discover/public/application/angular/doc_table/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/actions/columns.ts b/src/plugins/discover/public/application/angular/doc_table/actions/columns.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/actions/columns.ts rename to src/plugins/discover/public/application/angular/doc_table/actions/columns.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/_index.scss b/src/plugins/discover/public/application/angular/doc_table/components/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/_index.scss rename to src/plugins/discover/public/application/angular/doc_table/components/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/_table_header.scss b/src/plugins/discover/public/application/angular/doc_table/components/_table_header.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/_table_header.scss rename to src/plugins/discover/public/application/angular/doc_table/components/_table_header.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap b/src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap rename to src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap b/src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap rename to src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/index.ts b/src/plugins/discover/public/application/angular/doc_table/components/pager/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/index.ts rename to src/plugins/discover/public/application/angular/doc_table/components/pager/index.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx b/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx rename to src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx b/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx similarity index 92% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx rename to src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx index 6f1cf81e2c541..e95153d85b064 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx @@ -35,7 +35,7 @@ export function ToolBarPagerButtons(props: Props) { disabled={!props.hasPreviousPage} data-test-subj="btnPrevPage" aria-label={i18n.translate( - 'kbn.discover.docTable.pager.toolbarPagerButtons.previousButtonAriaLabel', + 'discover.docTable.pager.toolbarPagerButtons.previousButtonAriaLabel', { defaultMessage: 'Previous page in table', } @@ -49,7 +49,7 @@ export function ToolBarPagerButtons(props: Props) { disabled={!props.hasNextPage} data-test-subj="btnNextPage" aria-label={i18n.translate( - 'kbn.discover.docTable.pager.toolbarPagerButtons.nextButtonAriaLabel', + 'discover.docTable.pager.toolbarPagerButtons.nextButtonAriaLabel', { defaultMessage: 'Next page in table', } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx b/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx rename to src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_text.tsx b/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_text.tsx rename to src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx index 84338d817c86b..46e3cd9511eb5 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/pager/tool_bar_pager_text.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx @@ -30,7 +30,7 @@ export function ToolBarPagerText({ startItem, endItem, totalItems }: Props) {
diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header.ts b/src/plugins/discover/public/application/angular/doc_table/components/table_header.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header.ts rename to src/plugins/discover/public/application/angular/doc_table/components/table_header.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap b/src/plugins/discover/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap rename to src/plugins/discover/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/helpers.tsx b/src/plugins/discover/public/application/angular/doc_table/components/table_header/helpers.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/helpers.tsx rename to src/plugins/discover/public/application/angular/doc_table/components/table_header/helpers.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/table_header.test.tsx b/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx similarity index 99% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/table_header.test.tsx rename to src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx index 89f73022627c5..b201bea26503e 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/table_header.test.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx @@ -25,8 +25,6 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { SortOrder } from './helpers'; import { IndexPattern, IFieldType } from '../../../../../kibana_services'; -jest.mock('ui/new_platform'); - function getMockIndexPattern() { return ({ id: 'test', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/table_header.tsx b/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/table_header.tsx rename to src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/table_header_column.tsx b/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header_column.tsx similarity index 88% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/table_header_column.tsx rename to src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header_column.tsx index d1a9a5146fb8a..4c09ff8701f30 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_header/table_header_column.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header_column.tsx @@ -80,21 +80,21 @@ export function TableHeaderColumn({ const getSortButtonAriaLabel = () => { const sortAscendingMessage = i18n.translate( - 'kbn.docTable.tableHeader.sortByColumnAscendingAriaLabel', + 'discover.docTable.tableHeader.sortByColumnAscendingAriaLabel', { defaultMessage: 'Sort {columnName} ascending', values: { columnName: name }, } ); const sortDescendingMessage = i18n.translate( - 'kbn.docTable.tableHeader.sortByColumnDescendingAriaLabel', + 'discover.docTable.tableHeader.sortByColumnDescendingAriaLabel', { defaultMessage: 'Sort {columnName} descending', values: { columnName: name }, } ); const stopSortingMessage = i18n.translate( - 'kbn.docTable.tableHeader.sortByColumnUnsortedAriaLabel', + 'discover.docTable.tableHeader.sortByColumnUnsortedAriaLabel', { defaultMessage: 'Stop sorting on {columnName}', values: { columnName: name }, @@ -126,42 +126,42 @@ export function TableHeaderColumn({ // Remove Button { active: isRemoveable && typeof onRemoveColumn === 'function', - ariaLabel: i18n.translate('kbn.docTable.tableHeader.removeColumnButtonAriaLabel', { + ariaLabel: i18n.translate('discover.docTable.tableHeader.removeColumnButtonAriaLabel', { defaultMessage: 'Remove {columnName} column', values: { columnName: name }, }), className: 'fa fa-remove kbnDocTableHeader__move', onClick: () => onRemoveColumn && onRemoveColumn(name), testSubject: `docTableRemoveHeader-${name}`, - tooltip: i18n.translate('kbn.docTable.tableHeader.removeColumnButtonTooltip', { + tooltip: i18n.translate('discover.docTable.tableHeader.removeColumnButtonTooltip', { defaultMessage: 'Remove Column', }), }, // Move Left Button { active: colLeftIdx >= 0 && typeof onMoveColumn === 'function', - ariaLabel: i18n.translate('kbn.docTable.tableHeader.moveColumnLeftButtonAriaLabel', { + ariaLabel: i18n.translate('discover.docTable.tableHeader.moveColumnLeftButtonAriaLabel', { defaultMessage: 'Move {columnName} column to the left', values: { columnName: name }, }), className: 'fa fa-angle-double-left kbnDocTableHeader__move', onClick: () => onMoveColumn && onMoveColumn(name, colLeftIdx), testSubject: `docTableMoveLeftHeader-${name}`, - tooltip: i18n.translate('kbn.docTable.tableHeader.moveColumnLeftButtonTooltip', { + tooltip: i18n.translate('discover.docTable.tableHeader.moveColumnLeftButtonTooltip', { defaultMessage: 'Move column to the left', }), }, // Move Right Button { active: colRightIdx >= 0 && typeof onMoveColumn === 'function', - ariaLabel: i18n.translate('kbn.docTable.tableHeader.moveColumnRightButtonAriaLabel', { + ariaLabel: i18n.translate('discover.docTable.tableHeader.moveColumnRightButtonAriaLabel', { defaultMessage: 'Move {columnName} column to the right', values: { columnName: name }, }), className: 'fa fa-angle-double-right kbnDocTableHeader__move', onClick: () => onMoveColumn && onMoveColumn(name, colRightIdx), testSubject: `docTableMoveRightHeader-${name}`, - tooltip: i18n.translate('kbn.docTable.tableHeader.moveColumnRightButtonTooltip', { + tooltip: i18n.translate('discover.docTable.tableHeader.moveColumnRightButtonTooltip', { defaultMessage: 'Move column to the right', }), }, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row.ts b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts similarity index 96% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row.ts rename to src/plugins/discover/public/application/angular/doc_table/components/table_row.ts index 698bfe7416d42..3b48c4c79365e 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row.ts +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts @@ -23,15 +23,15 @@ import $ from 'jquery'; import rison from 'rison-node'; import '../../doc_viewer'; // @ts-ignore -import { noWhiteSpace } from '../../../../../../common/utils/no_white_space'; +import { noWhiteSpace } from '../../../../../../../legacy/core_plugins/kibana/common/utils/no_white_space'; import openRowHtml from './table_row/open.html'; import detailsHtml from './table_row/details.html'; -import { dispatchRenderComplete } from '../../../../../../../../../plugins/kibana_utils/public'; +import { dispatchRenderComplete } from '../../../../../../kibana_utils/public'; import cellTemplateHtml from '../components/table_row/cell.html'; import truncateByHeightTemplateHtml from '../components/table_row/truncate_by_height.html'; -import { esFilters } from '../../../../../../../../../plugins/data/public'; +import { esFilters } from '../../../../../../data/public'; import { getServices } from '../../../../kibana_services'; // guesstimate at the minimum number of chars wide cells in the table should be diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/_cell.scss b/src/plugins/discover/public/application/angular/doc_table/components/table_row/_cell.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/_cell.scss rename to src/plugins/discover/public/application/angular/doc_table/components/table_row/_cell.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/_details.scss b/src/plugins/discover/public/application/angular/doc_table/components/table_row/_details.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/_details.scss rename to src/plugins/discover/public/application/angular/doc_table/components/table_row/_details.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/_index.scss b/src/plugins/discover/public/application/angular/doc_table/components/table_row/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/_index.scss rename to src/plugins/discover/public/application/angular/doc_table/components/table_row/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/_open.scss b/src/plugins/discover/public/application/angular/doc_table/components/table_row/_open.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/_open.scss rename to src/plugins/discover/public/application/angular/doc_table/components/table_row/_open.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/cell.html b/src/plugins/discover/public/application/angular/doc_table/components/table_row/cell.html new file mode 100644 index 0000000000000..e8c4fceeca7ff --- /dev/null +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_row/cell.html @@ -0,0 +1,37 @@ +<% +var attributes = ''; +if (timefield) { + attributes='class="eui-textNoWrap" width="1%"'; +} else if (sourcefield) { + attributes='class="eui-textBreakAll eui-textBreakWord"'; +} else { + attributes='class="kbnDocTableCell__dataField eui-textBreakAll eui-textBreakWord"'; +} +%> + data-test-subj="docTableField"> + <%= formatted %> + + <% if (filterable) { %> + + + <% } %> + + diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/details.html b/src/plugins/discover/public/application/angular/doc_table/components/table_row/details.html similarity index 89% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/details.html rename to src/plugins/discover/public/application/angular/doc_table/components/table_row/details.html index d149a9023816a..37ae08246d1d3 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/details.html +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_row/details.html @@ -8,7 +8,7 @@

@@ -22,7 +22,7 @@ data-test-subj="docTableRowAction" ng-href="{{ getContextAppHref() }}" ng-if="indexPattern.isTimeBased()" - i18n-id="kbn.docTable.tableRow.viewSurroundingDocumentsLinkText" + i18n-id="discover.docTable.tableRow.viewSurroundingDocumentsLinkText" i18n-default-message="View surrounding documents" >
@@ -31,7 +31,7 @@ class="euiLink" data-test-subj="docTableRowAction" ng-href="#/discover/doc/{{indexPattern.id}}/{{row._index}}?id={{uriEncodedId}}" - i18n-id="kbn.docTable.tableRow.viewSingleDocumentLinkText" + i18n-id="discover.docTable.tableRow.viewSingleDocumentLinkText" i18n-default-message="View single document" >
diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/open.html b/src/plugins/discover/public/application/angular/doc_table/components/table_row/open.html new file mode 100644 index 0000000000000..6a14b6fc70348 --- /dev/null +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_row/open.html @@ -0,0 +1,10 @@ + + + diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/truncate_by_height.html b/src/plugins/discover/public/application/angular/doc_table/components/table_row/truncate_by_height.html similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/components/table_row/truncate_by_height.html rename to src/plugins/discover/public/application/angular/doc_table/components/table_row/truncate_by_height.html diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/doc_table.html b/src/plugins/discover/public/application/angular/doc_table/doc_table.html similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/doc_table.html rename to src/plugins/discover/public/application/angular/doc_table/doc_table.html index 3ce43426caf44..bb8cc4b9ee4c2 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/doc_table.html +++ b/src/plugins/discover/public/application/angular/doc_table/doc_table.html @@ -110,7 +110,7 @@

diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/doc_table.ts b/src/plugins/discover/public/application/angular/doc_table/doc_table.ts similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/doc_table.ts rename to src/plugins/discover/public/application/angular/doc_table/doc_table.ts index 3cb3a460af649..66b162a4584a7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/doc_table.ts +++ b/src/plugins/discover/public/application/angular/doc_table/doc_table.ts @@ -18,12 +18,12 @@ */ import html from './doc_table.html'; -import { dispatchRenderComplete } from '../../../../../../../../plugins/kibana_utils/public'; +import { dispatchRenderComplete } from '../../../../../kibana_utils/public'; // @ts-ignore import { getLimitedSearchResultsMessage } from './doc_table_strings'; import { getServices } from '../../../kibana_services'; -interface LazyScope extends ng.IScope { +export interface LazyScope extends ng.IScope { [key: string]: any; } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/doc_table_strings.js b/src/plugins/discover/public/application/angular/doc_table/doc_table_strings.js similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/doc_table_strings.js rename to src/plugins/discover/public/application/angular/doc_table/doc_table_strings.js index 56e0a580ff346..f1d65207c70b9 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/doc_table_strings.js +++ b/src/plugins/discover/public/application/angular/doc_table/doc_table_strings.js @@ -25,7 +25,7 @@ import { i18n } from '@kbn/i18n'; * @param resultCount {Number} */ export function getLimitedSearchResultsMessage(resultCount) { - return i18n.translate('kbn.docTable.limitedSearchResultLabel', { + return i18n.translate('discover.docTable.limitedSearchResultLabel', { defaultMessage: 'Limited to {resultCount} results. Refine your search.', values: { resultCount }, }); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/index.ts b/src/plugins/discover/public/application/angular/doc_table/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/index.ts rename to src/plugins/discover/public/application/angular/doc_table/index.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/infinite_scroll.ts b/src/plugins/discover/public/application/angular/doc_table/infinite_scroll.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/infinite_scroll.ts rename to src/plugins/discover/public/application/angular/doc_table/infinite_scroll.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/get_default_sort.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/get_default_sort.ts rename to src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/get_sort.test.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/get_sort.test.ts rename to src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/get_sort.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts similarity index 97% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/get_sort.ts rename to src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts index a8dbaa50e5aa8..aa23aa4390673 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/get_sort.ts +++ b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts @@ -18,7 +18,7 @@ */ import _ from 'lodash'; -import { IndexPattern } from '../../../../../../../../../plugins/data/public'; +import { IndexPattern } from '../../../../../../data/public'; export type SortPairObj = Record; export type SortPairArr = [string, string]; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/get_sort_for_search_source.ts b/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/get_sort_for_search_source.ts rename to src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/pager/index.js b/src/plugins/discover/public/application/angular/doc_table/lib/pager/index.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/pager/index.js rename to src/plugins/discover/public/application/angular/doc_table/lib/pager/index.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/pager/pager.js b/src/plugins/discover/public/application/angular/doc_table/lib/pager/pager.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/pager/pager.js rename to src/plugins/discover/public/application/angular/doc_table/lib/pager/pager.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/pager/pager_factory.ts b/src/plugins/discover/public/application/angular/doc_table/lib/pager/pager_factory.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_table/lib/pager/pager_factory.ts rename to src/plugins/discover/public/application/angular/doc_table/lib/pager/pager_factory.ts diff --git a/src/plugins/discover/public/application/angular/doc_viewer.tsx b/src/plugins/discover/public/application/angular/doc_viewer.tsx new file mode 100644 index 0000000000000..d023af1c44487 --- /dev/null +++ b/src/plugins/discover/public/application/angular/doc_viewer.tsx @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { DocViewer } from '../components/doc_viewer/doc_viewer'; + +export function createDocViewerDirective(reactDirective: any) { + return reactDirective( + (props: any) => { + return ; + }, + [ + 'hit', + ['indexPattern', { watchDepth: 'reference' }], + ['filter', { watchDepth: 'reference' }], + ['columns', { watchDepth: 'collection' }], + ['onAddColumn', { watchDepth: 'reference' }], + ['onRemoveColumn', { watchDepth: 'reference' }], + ], + { + restrict: 'E', + scope: { + hit: '=', + indexPattern: '=', + filter: '=?', + columns: '=?', + onAddColumn: '=?', + onRemoveColumn: '=?', + }, + } + ); +} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/get_painless_error.ts b/src/plugins/discover/public/application/angular/get_painless_error.ts similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/get_painless_error.ts rename to src/plugins/discover/public/application/angular/get_painless_error.ts index 100d9cdac133b..e1e98d9df27b1 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/get_painless_error.ts +++ b/src/plugins/discover/public/application/angular/get_painless_error.ts @@ -40,7 +40,7 @@ export function getPainlessError(error: Error) { return { lang, script, - message: i18n.translate('kbn.discover.painlessError.painlessScriptedFieldErrorMessage', { + message: i18n.translate('discover.painlessError.painlessScriptedFieldErrorMessage', { defaultMessage: "Error with Painless scripted field '{script}'.", values: { script }, }), diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/helpers/index.ts b/src/plugins/discover/public/application/angular/helpers/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/helpers/index.ts rename to src/plugins/discover/public/application/angular/helpers/index.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/helpers/point_series.ts b/src/plugins/discover/public/application/angular/helpers/point_series.ts similarity index 96% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/helpers/point_series.ts rename to src/plugins/discover/public/application/angular/helpers/point_series.ts index 02dd024b09812..db0ca85b0399a 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/helpers/point_series.ts +++ b/src/plugins/discover/public/application/angular/helpers/point_series.ts @@ -21,7 +21,7 @@ import { uniq } from 'lodash'; import { Duration, Moment } from 'moment'; import { Unit } from '@elastic/datemath'; -import { SerializedFieldFormat } from '../../../../../../../../plugins/expressions/common/types'; +import { SerializedFieldFormat } from '../../../../../expressions/common/types'; export interface Column { id: string; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/index.ts b/src/plugins/discover/public/application/angular/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/index.ts rename to src/plugins/discover/public/application/angular/index.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/response_handler.js b/src/plugins/discover/public/application/angular/response_handler.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/response_handler.js rename to src/plugins/discover/public/application/angular/response_handler.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/application.ts b/src/plugins/discover/public/application/application.ts similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/application.ts rename to src/plugins/discover/public/application/application.ts index 7a4819bb0f2d1..8167d4f903195 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/application.ts +++ b/src/plugins/discover/public/application/application.ts @@ -17,6 +17,7 @@ * under the License. */ +import './index.scss'; import angular from 'angular'; /** diff --git a/src/plugins/discover/public/application/components/_index.scss b/src/plugins/discover/public/application/components/_index.scss new file mode 100644 index 0000000000000..91fb3df79b177 --- /dev/null +++ b/src/plugins/discover/public/application/components/_index.scss @@ -0,0 +1,3 @@ +@import 'doc_viewer/index'; +@import 'fetch_error/index'; +@import 'sidebar/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx b/src/plugins/discover/public/application/components/doc/doc.test.tsx similarity index 92% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx rename to src/plugins/discover/public/application/components/doc/doc.test.tsx index 1d19dc112d193..2cbc1547d1082 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx +++ b/src/plugins/discover/public/application/components/doc/doc.test.tsx @@ -25,12 +25,24 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; jest.mock('../../../kibana_services', () => { + let registry: any[] = []; + return { getServices: () => ({ metadata: { branch: 'test', }, - DocViewer: () => null, + }), + getDocViewsRegistry: () => ({ + addDocView(view: any) { + registry.push(view); + }, + getDocViewsSorted() { + return registry; + }, + resetRegistry: () => { + registry = []; + }, }), }; }); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx b/src/plugins/discover/public/application/components/doc/doc.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx rename to src/plugins/discover/public/application/components/doc/doc.tsx index f3ceaef57d700..0e31ded267b75 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx +++ b/src/plugins/discover/public/application/components/doc/doc.tsx @@ -22,7 +22,8 @@ import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent } from '@elastic import { IndexPatternsContract } from 'src/plugins/data/public'; import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search'; import { getServices } from '../../../kibana_services'; -import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; +import { DocViewer } from '../doc_viewer/doc_viewer'; +import { ElasticSearchHit } from '../../doc_views/doc_views_types'; export interface ElasticSearchResult { hits: { @@ -61,7 +62,6 @@ export interface DocProps { } export function Doc(props: DocProps) { - const { DocViewer } = getServices(); const [reqState, hit, indexPattern] = useEsDocSearch(props); return ( @@ -74,7 +74,7 @@ export function Doc(props: DocProps) { iconType="alert" title={ @@ -88,13 +88,13 @@ export function Doc(props: DocProps) { iconType="alert" title={ } > @@ -107,13 +107,13 @@ export function Doc(props: DocProps) { iconType="alert" title={ } > {' '} @@ -124,7 +124,7 @@ export function Doc(props: DocProps) { target="_blank" > @@ -134,7 +134,7 @@ export function Doc(props: DocProps) { {reqState === ElasticRequestState.Loading && ( {' '} - + )} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.test.tsx b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.test.tsx rename to src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts similarity index 97% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts rename to src/plugins/discover/public/application/components/doc/use_es_doc_search.ts index 2cd264578a596..00496a3a72681 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts @@ -19,7 +19,7 @@ import { useEffect, useState } from 'react'; import { IndexPattern } from '../../../kibana_services'; import { DocProps } from './doc'; -import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; +import { ElasticSearchHit } from '../../doc_views/doc_views_types'; export enum ElasticRequestState { Loading, diff --git a/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap b/src/plugins/discover/public/application/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap rename to src/plugins/discover/public/application/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap diff --git a/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap b/src/plugins/discover/public/application/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap rename to src/plugins/discover/public/application/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap diff --git a/src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss b/src/plugins/discover/public/application/components/doc_viewer/_doc_viewer.scss similarity index 77% rename from src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss rename to src/plugins/discover/public/application/components/doc_viewer/_doc_viewer.scss index 25aa530976719..ec2beca15a546 100644 --- a/src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss +++ b/src/plugins/discover/public/application/components/doc_viewer/_doc_viewer.scss @@ -43,6 +43,14 @@ } .kbnDocViewer__buttons { width: 60px; + + // Show all icons if one is focused, + // IE doesn't support, but the fallback is just the focused button becomes visible + &:focus-within { + .kbnDocViewer__actionButton { + opacity: 1; + } + } } .kbnDocViewer__field { @@ -51,7 +59,12 @@ .kbnDocViewer__actionButton { opacity: 0; + + &:focus { + opacity: 1; + } } + .kbnDocViewer__warning { margin-right: $euiSizeS; } diff --git a/src/plugins/discover/public/components/doc_viewer/_index.scss b/src/plugins/discover/public/application/components/doc_viewer/_index.scss similarity index 100% rename from src/plugins/discover/public/components/doc_viewer/_index.scss rename to src/plugins/discover/public/application/components/doc_viewer/_index.scss diff --git a/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx similarity index 95% rename from src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx rename to src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx index 6f29f10ddd026..3710ce72533db 100644 --- a/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx @@ -21,10 +21,10 @@ import { mount, shallow } from 'enzyme'; import { DocViewer } from './doc_viewer'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; -import { getDocViewsRegistry } from '../../services'; +import { getDocViewsRegistry } from '../../../kibana_services'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; -jest.mock('../../services', () => { +jest.mock('../../../kibana_services', () => { let registry: any[] = []; return { getDocViewsRegistry: () => ({ diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.tsx new file mode 100644 index 0000000000000..dce6de150155c --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.tsx @@ -0,0 +1,62 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { EuiTabbedContent } from '@elastic/eui'; +import { getDocViewsRegistry } from '../../../kibana_services'; +import { DocViewerTab } from './doc_viewer_tab'; +import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; + +/** + * Rendering tabs with different views of 1 Elasticsearch hit in Discover. + * The tabs are provided by the `docs_views` registry. + * A view can contain a React `component`, or any JS framework by using + * a `render` function. + */ +export function DocViewer(renderProps: DocViewRenderProps) { + const docViewsRegistry = getDocViewsRegistry(); + const tabs = docViewsRegistry + .getDocViewsSorted(renderProps.hit) + .map(({ title, render, component }: DocView, idx: number) => { + return { + id: title, + name: title, + content: ( + + ), + }; + }); + + if (!tabs.length) { + // There there's a minimum of 2 tabs active in Discover. + // This condition takes care of unit tests with 0 tabs. + return null; + } + + return ( +
+ +
+ ); +} diff --git a/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_error.tsx similarity index 94% rename from src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx rename to src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_error.tsx index 387e57dc8a7e3..d1c016721f51f 100644 --- a/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_error.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; -import { formatMsg, formatStack } from '../../../../kibana_legacy/public'; +import { formatMsg, formatStack } from '../../../../../kibana_legacy/public'; interface Props { error: Error | string; diff --git a/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx similarity index 100% rename from src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx rename to src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx diff --git a/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx similarity index 100% rename from src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx rename to src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx diff --git a/src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx similarity index 100% rename from src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx rename to src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/fetch_error/_fetch_error.scss b/src/plugins/discover/public/application/components/fetch_error/_fetch_error.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/fetch_error/_fetch_error.scss rename to src/plugins/discover/public/application/components/fetch_error/_fetch_error.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/fetch_error/_index.scss b/src/plugins/discover/public/application/components/fetch_error/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/fetch_error/_index.scss rename to src/plugins/discover/public/application/components/fetch_error/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/fetch_error/fetch_error.tsx b/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/fetch_error/fetch_error.tsx rename to src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx index f8fc966dec351..e16089500d3e5 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/fetch_error/fetch_error.tsx +++ b/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx @@ -46,21 +46,21 @@ const DiscoverFetchError = ({ fetchError }: Props) => { body = (

), managementLink: ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/fetch_error/index.js b/src/plugins/discover/public/application/components/fetch_error/index.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/fetch_error/index.js rename to src/plugins/discover/public/application/components/fetch_error/index.js diff --git a/src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap b/src/plugins/discover/public/application/components/field_name/__snapshots__/field_name.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap rename to src/plugins/discover/public/application/components/field_name/__snapshots__/field_name.test.tsx.snap diff --git a/src/plugins/discover/public/components/field_name/field_name.test.tsx b/src/plugins/discover/public/application/components/field_name/field_name.test.tsx similarity index 100% rename from src/plugins/discover/public/components/field_name/field_name.test.tsx rename to src/plugins/discover/public/application/components/field_name/field_name.test.tsx diff --git a/src/plugins/discover/public/components/field_name/field_name.tsx b/src/plugins/discover/public/application/components/field_name/field_name.tsx similarity index 96% rename from src/plugins/discover/public/components/field_name/field_name.tsx rename to src/plugins/discover/public/application/components/field_name/field_name.tsx index f7f1433328adf..cf11f971ef76c 100644 --- a/src/plugins/discover/public/components/field_name/field_name.tsx +++ b/src/plugins/discover/public/application/components/field_name/field_name.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { FieldIcon, FieldIconProps } from '../../../../kibana_react/public'; +import { FieldIcon, FieldIconProps } from '../../../../../kibana_react/public'; import { shortenDottedString } from '../../helpers'; import { getFieldTypeName } from './field_type_name'; diff --git a/src/plugins/discover/public/components/field_name/field_type_name.ts b/src/plugins/discover/public/application/components/field_name/field_type_name.ts similarity index 100% rename from src/plugins/discover/public/components/field_name/field_type_name.ts rename to src/plugins/discover/public/application/components/field_name/field_type_name.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/help_menu/help_menu_util.js b/src/plugins/discover/public/application/components/help_menu/help_menu_util.js similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/help_menu/help_menu_util.js rename to src/plugins/discover/public/application/components/help_menu/help_menu_util.js index 37fa79b490d56..03ab44966f796 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/help_menu/help_menu_util.js +++ b/src/plugins/discover/public/application/components/help_menu/help_menu_util.js @@ -23,7 +23,7 @@ const { docLinks } = getServices(); export function addHelpMenuToAppChrome(chrome) { chrome.setHelpExtension({ - appName: i18n.translate('kbn.discover.helpMenu.appName', { + appName: i18n.translate('discover.helpMenu.appName', { defaultMessage: 'Discover', }), links: [ diff --git a/src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap b/src/plugins/discover/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap rename to src/plugins/discover/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap diff --git a/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx b/src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx similarity index 95% rename from src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx rename to src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx index 7e7f80c6aaa56..3cbcab5036251 100644 --- a/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx +++ b/src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { JsonCodeBlock } from './json_code_block'; -import { IndexPattern } from '../../../../data/public'; +import { IndexPattern } from '../../../../../data/public'; it('returns the `JsonCodeEditor` component', () => { const props = { diff --git a/src/plugins/discover/public/components/json_code_block/json_code_block.tsx b/src/plugins/discover/public/application/components/json_code_block/json_code_block.tsx similarity index 100% rename from src/plugins/discover/public/components/json_code_block/json_code_block.tsx rename to src/plugins/discover/public/application/components/json_code_block/json_code_block.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap b/src/plugins/discover/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap rename to src/plugins/discover/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/_index.scss b/src/plugins/discover/public/application/components/sidebar/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/_index.scss rename to src/plugins/discover/public/application/components/sidebar/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/_sidebar.scss b/src/plugins/discover/public/application/components/sidebar/_sidebar.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/_sidebar.scss rename to src/plugins/discover/public/application/components/sidebar/_sidebar.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/change_indexpattern.tsx b/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx similarity index 97% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/change_indexpattern.tsx rename to src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx index 60842ac81ee03..9d304bfa9c27d 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/change_indexpattern.tsx +++ b/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx @@ -82,7 +82,7 @@ export function ChangeIndexPattern({ >

- {i18n.translate('kbn.discover.fieldChooser.indexPattern.changeIndexPatternTitle', { + {i18n.translate('discover.fieldChooser.indexPattern.changeIndexPatternTitle', { defaultMessage: 'Change index pattern', })} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field.test.tsx rename to src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx index fdae2c0c16c9f..7372fab075ea3 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx @@ -26,8 +26,8 @@ import StubIndexPattern from 'test_utils/stub_index_pattern'; import stubbedLogstashFields from 'fixtures/logstash_fields'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { DiscoverField } from './discover_field'; -import { coreMock } from '../../../../../../../../core/public/mocks'; -import { IndexPatternField } from '../../../../../../../../plugins/data/public'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { IndexPatternField } from '../../../../../data/public'; jest.mock('../../../kibana_services', () => ({ getServices: () => ({ diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx similarity index 90% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field.tsx rename to src/plugins/discover/public/application/components/sidebar/discover_field.tsx index f7acb751c487b..9e5429882e3c3 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -20,9 +20,9 @@ import React from 'react'; import { EuiButton, EuiToolTip, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DiscoverFieldDetails } from './discover_field_details'; -import { FieldIcon } from '../../../../../../../../plugins/kibana_react/public'; +import { FieldIcon } from '../../../../../kibana_react/public'; import { FieldDetails } from './types'; -import { IndexPatternField, IndexPattern } from '../../../../../../../../plugins/data/public'; +import { IndexPatternField, IndexPattern } from '../../../../../data/public'; import { shortenDottedString } from '../../helpers'; import { getFieldTypeName } from './lib/get_field_type_name'; @@ -82,21 +82,18 @@ export function DiscoverField({ selected, useShortDots, }: DiscoverFieldProps) { - const addLabel = i18n.translate('kbn.discover.fieldChooser.discoverField.addButtonLabel', { + const addLabel = i18n.translate('discover.fieldChooser.discoverField.addButtonLabel', { defaultMessage: 'Add', }); - const addLabelAria = i18n.translate( - 'kbn.discover.fieldChooser.discoverField.addButtonAriaLabel', - { - defaultMessage: 'Add {field} to table', - values: { field: field.name }, - } - ); - const removeLabel = i18n.translate('kbn.discover.fieldChooser.discoverField.removeButtonLabel', { + const addLabelAria = i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { + defaultMessage: 'Add {field} to table', + values: { field: field.name }, + }); + const removeLabel = i18n.translate('discover.fieldChooser.discoverField.removeButtonLabel', { defaultMessage: 'Remove', }); const removeLabelAria = i18n.translate( - 'kbn.discover.fieldChooser.discoverField.removeButtonAriaLabel', + 'discover.fieldChooser.discoverField.removeButtonAriaLabel', { defaultMessage: 'Remove {field} from table', values: { field: field.name }, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_bucket.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_bucket.tsx similarity index 86% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_bucket.tsx rename to src/plugins/discover/public/application/components/sidebar/discover_field_bucket.tsx index 5a2b855828f53..398a945e0f876 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_bucket.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_bucket.tsx @@ -21,7 +21,7 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui' import { i18n } from '@kbn/i18n'; import { StringFieldProgressBar } from './string_progress_bar'; import { Bucket } from './types'; -import { IndexPatternField } from '../../../../../../../../plugins/data/public'; +import { IndexPatternField } from '../../../../../data/public'; interface Props { bucket: Bucket; @@ -30,18 +30,15 @@ interface Props { } export function DiscoverFieldBucket({ field, bucket, onAddFilter }: Props) { - const emptyTxt = i18n.translate('kbn.discover.fieldChooser.detailViews.emptyStringText', { + const emptyTxt = i18n.translate('discover.fieldChooser.detailViews.emptyStringText', { defaultMessage: 'Empty string', }); - const addLabel = i18n.translate( - 'kbn.discover.fieldChooser.detailViews.filterValueButtonAriaLabel', - { - defaultMessage: 'Filter for {field}: "{value}"', - values: { value: bucket.value, field: field.name }, - } - ); + const addLabel = i18n.translate('discover.fieldChooser.detailViews.filterValueButtonAriaLabel', { + defaultMessage: 'Filter for {field}: "{value}"', + values: { value: bucket.value, field: field.name }, + }); const removeLabel = i18n.translate( - 'kbn.discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel', + 'discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel', { defaultMessage: 'Filter out {field}: "{value}"', values: { value: bucket.value, field: field.name }, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_details.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx similarity index 92% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_details.tsx rename to src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx index 6266c29745718..dd5730c8e9496 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_details.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx @@ -22,7 +22,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { DiscoverFieldBucket } from './discover_field_bucket'; import { getWarnings } from './lib/get_warnings'; import { Bucket, FieldDetails } from './types'; -import { IndexPatternField, IndexPattern } from '../../../../../../../../plugins/data/public'; +import { IndexPatternField, IndexPattern } from '../../../../../data/public'; interface DiscoverFieldDetailsProps { field: IndexPatternField; @@ -44,7 +44,7 @@ export function DiscoverFieldDetails({ {!details.error && ( {' '} {!indexPattern.metaFields.includes(field.name) && !field.scripted ? ( @@ -56,7 +56,7 @@ export function DiscoverFieldDetails({ )}{' '} / {details.total}{' '} @@ -84,7 +84,7 @@ export function DiscoverFieldDetails({ data-test-subj={`fieldVisualize-${field.name}`} > {warnings.length > 0 && ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_search.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_search.test.tsx rename to src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_search.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx similarity index 90% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_search.tsx rename to src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx index 57f69693cb544..a533b798ad09f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_field_search.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx @@ -67,16 +67,16 @@ export interface Props { * Additionally there's a button displayed that allows the user to show/hide more filter fields */ export function DiscoverFieldSearch({ onChange, value, types }: Props) { - const searchPlaceholder = i18n.translate('kbn.discover.fieldChooser.searchPlaceHolder', { + const searchPlaceholder = i18n.translate('discover.fieldChooser.searchPlaceHolder', { defaultMessage: 'Search field names', }); - const aggregatableLabel = i18n.translate('kbn.discover.fieldChooser.filter.aggregatableLabel', { + const aggregatableLabel = i18n.translate('discover.fieldChooser.filter.aggregatableLabel', { defaultMessage: 'Aggregatable', }); - const searchableLabel = i18n.translate('kbn.discover.fieldChooser.filter.searchableLabel', { + const searchableLabel = i18n.translate('discover.fieldChooser.filter.searchableLabel', { defaultMessage: 'Searchable', }); - const typeLabel = i18n.translate('kbn.discover.fieldChooser.filter.typeLabel', { + const typeLabel = i18n.translate('discover.fieldChooser.filter.typeLabel', { defaultMessage: 'Type', }); const typeOptions = types @@ -101,10 +101,10 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { } const filterBtnAriaLabel = isPopoverOpen - ? i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', { + ? i18n.translate('discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', { defaultMessage: 'Hide field filter settings', }) - : i18n.translate('kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel', { + : i18n.translate('discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel', { defaultMessage: 'Show field filter settings', }); @@ -172,7 +172,7 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { onClick={handleFacetButtonClicked} > @@ -191,7 +191,7 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { onChange={(e: React.ChangeEvent) => handleValueChange(id, e.target.value) } - aria-label={i18n.translate('kbn.discover.fieldChooser.filter.fieldSelectorLabel', { + aria-label={i18n.translate('discover.fieldChooser.filter.fieldSelectorLabel', { defaultMessage: 'Selection of {id} filter options', values: { id }, })} @@ -278,14 +278,14 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { button={buttonContent} > - {i18n.translate('kbn.discover.fieldChooser.filter.filterByTypeLabel', { + {i18n.translate('discover.fieldChooser.filter.filterByTypeLabel', { defaultMessage: 'Filter by type', })} {selectionPanel} ({ getServices: () => ({ @@ -58,6 +58,10 @@ jest.mock('../../../kibana_services', () => ({ }), })); +jest.mock('./lib/get_index_pattern_field_list', () => ({ + getIndexPatternFieldList: jest.fn(indexPattern => indexPattern.fields), +})); + function getCompProps() { const indexPattern = new StubIndexPattern( 'logstash-*', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx similarity index 91% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_sidebar.tsx rename to src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index 0adda0e484843..74d1347b1694c 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -24,14 +24,14 @@ import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { DiscoverField } from './discover_field'; import { DiscoverIndexPattern } from './discover_index_pattern'; import { DiscoverFieldSearch } from './discover_field_search'; -import { IndexPatternAttributes } from '../../../../../../../../plugins/data/common'; -import { SavedObject } from '../../../../../../../../core/types'; +import { IndexPatternAttributes } from '../../../../../data/common'; +import { SavedObject } from '../../../../../../core/types'; import { groupFields } from './lib/group_fields'; import { - IndexPatternFieldList, + IIndexPatternFieldList, IndexPatternField, IndexPattern, -} from '../../../../../../../../plugins/data/public'; +} from '../../../../../data/public'; import { AppState } from '../../angular/discover_state'; import { getDetails } from './lib/get_details'; import { getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter'; @@ -96,14 +96,14 @@ export function DiscoverSidebar({ }: DiscoverSidebarProps) { const [openFieldMap, setOpenFieldMap] = useState(new Map()); const [showFields, setShowFields] = useState(false); - const [fields, setFields] = useState(null); + const [fields, setFields] = useState(null); const [fieldFilterState, setFieldFilterState] = useState(getDefaultFieldFilter()); - const services = getServices(); + const services = useMemo(() => getServices(), []); useEffect(() => { - const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts); + const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts, services); setFields(newFields); - }, [selectedIndexPattern, fieldCounts, hits]); + }, [selectedIndexPattern, fieldCounts, hits, services]); const onShowDetails = useCallback( (show: boolean, field: IndexPatternField) => { @@ -165,12 +165,9 @@ export function DiscoverSidebar({

@@ -229,7 +226,7 @@ export function DiscoverSidebar({

@@ -242,13 +239,13 @@ export function DiscoverSidebar({ aria-label={ showFields ? i18n.translate( - 'kbn.discover.fieldChooser.filter.indexAndFieldsSectionHideAriaLabel', + 'discover.fieldChooser.filter.indexAndFieldsSectionHideAriaLabel', { defaultMessage: 'Hide fields', } ) : i18n.translate( - 'kbn.discover.fieldChooser.filter.indexAndFieldsSectionShowAriaLabel', + 'discover.fieldChooser.filter.indexAndFieldsSectionShowAriaLabel', { defaultMessage: 'Show fields', } @@ -267,7 +264,7 @@ export function DiscoverSidebar({ > diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_sidebar_directive.ts b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_directive.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/discover_sidebar_directive.ts rename to src/plugins/discover/public/application/components/sidebar/discover_sidebar_directive.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/index.ts b/src/plugins/discover/public/application/components/sidebar/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/index.ts rename to src/plugins/discover/public/application/components/sidebar/index.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_calculator.js b/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.js similarity index 92% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_calculator.js rename to src/plugins/discover/public/application/components/sidebar/lib/field_calculator.js index 50f4e541bb205..c2d225360d0ef 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_calculator.js +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.js @@ -41,7 +41,7 @@ function getFieldValueCounts(params) { ) { return { error: i18n.translate( - 'kbn.discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForGeoFieldsErrorMessage', + 'discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForGeoFieldsErrorMessage', { defaultMessage: 'Analysis is not available for geo fields.', } @@ -71,7 +71,7 @@ function getFieldValueCounts(params) { if (params.hits.length - missing === 0) { return { error: i18n.translate( - 'kbn.discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage', + 'discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage', { defaultMessage: 'This field is present in your Elasticsearch mapping but not in the {hitsLength} documents shown in the doc table. You may still be able to visualize or search on it.', @@ -107,7 +107,7 @@ function _groupValues(allValues, params) { if (_.isObject(value) && !Array.isArray(value)) { throw new Error( i18n.translate( - 'kbn.discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForObjectFieldsErrorMessage', + 'discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForObjectFieldsErrorMessage', { defaultMessage: 'Analysis is not available for object fields.', } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_calculator.test.ts b/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts similarity index 97% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_calculator.test.ts rename to src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts index 98763937e888f..fdfc536485579 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_calculator.test.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_calculator.test.ts @@ -24,8 +24,8 @@ import realHits from 'fixtures/real_hits.js'; import StubIndexPattern from 'test_utils/stub_index_pattern'; // @ts-ignore import stubbedLogstashFields from 'fixtures/logstash_fields'; -import { coreMock } from '../../../../../../../../../core/public/mocks'; -import { IndexPattern } from '../../../../../../../../../plugins/data/public'; +import { coreMock } from '../../../../../../../core/public/mocks'; +import { IndexPattern } from '../../../../../../data/public'; // @ts-ignore import { fieldCalculator } from './field_calculator'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_filter.test.ts b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.test.ts similarity index 97% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_filter.test.ts rename to src/plugins/discover/public/application/components/sidebar/lib/field_filter.test.ts index ca0fcfc846362..1351433e9dd0e 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_filter.test.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.test.ts @@ -18,7 +18,7 @@ */ import { getDefaultFieldFilter, setFieldFilterProp, isFieldFiltered } from './field_filter'; -import { IndexPatternField } from '../../../../../../../../../plugins/data/public'; +import { IndexPatternField } from '../../../../../../data/public'; describe('field_filter', function() { it('getDefaultFieldFilter should return default filter state', function() { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_filter.ts b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts similarity index 96% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_filter.ts rename to src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts index ed7ad1a43fa8c..f0d9a2d8af20f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/field_filter.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/field_filter.ts @@ -17,7 +17,7 @@ * under the License. */ -import { IndexPatternField } from '../../../../../../../../../plugins/data/public'; +import { IndexPatternField } from '../../../../../../data/public'; export interface FieldFilterState { missing: boolean; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_details.ts b/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_details.ts rename to src/plugins/discover/public/application/components/sidebar/lib/get_details.ts index 9999b108c5cc1..7ac9f009d73d5 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_details.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts @@ -20,7 +20,7 @@ import { getVisualizeUrl, isFieldVisualizable } from './visualize_url_utils'; import { AppState } from '../../../angular/discover_state'; // @ts-ignore import { fieldCalculator } from './field_calculator'; -import { IndexPatternField, IndexPattern } from '../../../../../../../../../plugins/data/public'; +import { IndexPatternField, IndexPattern } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; export function getDetails( diff --git a/src/plugins/discover/public/application/components/sidebar/lib/get_field_type_name.ts b/src/plugins/discover/public/application/components/sidebar/lib/get_field_type_name.ts new file mode 100644 index 0000000000000..a67c20fc4f353 --- /dev/null +++ b/src/plugins/discover/public/application/components/sidebar/lib/get_field_type_name.ts @@ -0,0 +1,73 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export function getFieldTypeName(type: string) { + switch (type) { + case 'boolean': + return i18n.translate('discover.fieldNameIcons.booleanAriaLabel', { + defaultMessage: 'Boolean field', + }); + case 'conflict': + return i18n.translate('discover.fieldNameIcons.conflictFieldAriaLabel', { + defaultMessage: 'Conflicting field', + }); + case 'date': + return i18n.translate('discover.fieldNameIcons.dateFieldAriaLabel', { + defaultMessage: 'Date field', + }); + case 'geo_point': + return i18n.translate('discover.fieldNameIcons.geoPointFieldAriaLabel', { + defaultMessage: 'Geo point field', + }); + case 'geo_shape': + return i18n.translate('discover.fieldNameIcons.geoShapeFieldAriaLabel', { + defaultMessage: 'Geo shape field', + }); + case 'ip': + return i18n.translate('discover.fieldNameIcons.ipAddressFieldAriaLabel', { + defaultMessage: 'IP address field', + }); + case 'murmur3': + return i18n.translate('discover.fieldNameIcons.murmur3FieldAriaLabel', { + defaultMessage: 'Murmur3 field', + }); + case 'number': + return i18n.translate('discover.fieldNameIcons.numberFieldAriaLabel', { + defaultMessage: 'Number field', + }); + case 'source': + // Note that this type is currently not provided, type for _source is undefined + return i18n.translate('discover.fieldNameIcons.sourceFieldAriaLabel', { + defaultMessage: 'Source field', + }); + case 'string': + return i18n.translate('discover.fieldNameIcons.stringFieldAriaLabel', { + defaultMessage: 'String field', + }); + case 'nested': + return i18n.translate('discover.fieldNameIcons.nestedFieldAriaLabel', { + defaultMessage: 'Nested field', + }); + default: + return i18n.translate('discover.fieldNameIcons.unknownFieldAriaLabel', { + defaultMessage: 'Unknown field', + }); + } +} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_index_pattern_field_list.ts b/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts similarity index 77% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_index_pattern_field_list.ts rename to src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts index 1b906501c6fe7..63e1b761f4dd0 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_index_pattern_field_list.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts @@ -17,17 +17,15 @@ * under the License. */ import { difference, map } from 'lodash'; -import { - IndexPatternFieldList, - IndexPattern, - IndexPatternField, -} from '../../../../../../../../../plugins/data/public'; +import { IndexPattern, IndexPatternField } from 'src/plugins/data/public'; +import { DiscoverServices } from '../../../../build_services'; export function getIndexPatternFieldList( indexPattern: IndexPattern, - fieldCounts: Record -): IndexPatternFieldList { - if (!indexPattern || !fieldCounts) return new IndexPatternFieldList(indexPattern, []); + fieldCounts: Record, + { data }: DiscoverServices +) { + if (!indexPattern || !fieldCounts) return data.indexPatterns.createFieldList(indexPattern); const fieldSpecs = indexPattern.fields.slice(0); const fieldNamesInDocs = Object.keys(fieldCounts); @@ -40,5 +38,5 @@ export function getIndexPatternFieldList( } as IndexPatternField); }); - return new IndexPatternFieldList(indexPattern, fieldSpecs); + return data.indexPatterns.createFieldList(indexPattern, fieldSpecs); } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_warnings.ts b/src/plugins/discover/public/application/components/sidebar/lib/get_warnings.ts similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_warnings.ts rename to src/plugins/discover/public/application/components/sidebar/lib/get_warnings.ts index 51d18c03888a7..138e805405c89 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/get_warnings.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/get_warnings.ts @@ -17,7 +17,7 @@ * under the License. */ import { i18n } from '@kbn/i18n'; -import { IndexPatternField } from '../../../../../../../../../plugins/data/public'; +import { IndexPatternField } from '../../../../../../data/public'; export function getWarnings(field: IndexPatternField) { let warnings = []; @@ -25,7 +25,7 @@ export function getWarnings(field: IndexPatternField) { if (field.scripted) { warnings.push( i18n.translate( - 'kbn.discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription', + 'discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription', { defaultMessage: 'Scripted fields can take a long time to execute.', } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/group_fields.test.ts b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/group_fields.test.ts rename to src/plugins/discover/public/application/components/sidebar/lib/group_fields.test.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/group_fields.tsx rename to src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx index 85ca8d6a4e15e..e4e5f00172371 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx @@ -16,10 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { - IndexPatternFieldList, - IndexPatternField, -} from '../../../../../../../../../plugins/data/public'; +import { IIndexPatternFieldList, IndexPatternField } from 'src/plugins/data/public'; import { FieldFilterState, isFieldFiltered } from './field_filter'; interface GroupedFields { @@ -32,7 +29,7 @@ interface GroupedFields { * group the fields into selected, popular and unpopular, filter by fieldFilterState */ export function groupFields( - fields: IndexPatternFieldList | null, + fields: IIndexPatternFieldList | null, columns: string[], popularLimit: number, fieldCounts: Record, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/visualize_url_utils.ts b/src/plugins/discover/public/application/components/sidebar/lib/visualize_url_utils.ts similarity index 96% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/visualize_url_utils.ts rename to src/plugins/discover/public/application/components/sidebar/lib/visualize_url_utils.ts index 968ceeeab73a5..52bc005a0bf4a 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/lib/visualize_url_utils.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/visualize_url_utils.ts @@ -24,13 +24,10 @@ import { IIndexPattern, IndexPatternField, KBN_FIELD_TYPES, -} from '../../../../../../../../../plugins/data/public'; +} from '../../../../../../data/public'; import { AppState } from '../../../angular/discover_state'; import { DiscoverServices } from '../../../../build_services'; -import { - VisualizationsStart, - VisTypeAlias, -} from '../../../../../../../../../plugins/visualizations/public'; +import { VisualizationsStart, VisTypeAlias } from '../../../../../../visualizations/public'; function getMapsAppBaseUrl(visualizations: VisualizationsStart) { const mapsAppVisAlias = visualizations.getAliases().find(({ name }) => { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/string_progress_bar.tsx b/src/plugins/discover/public/application/components/sidebar/string_progress_bar.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/string_progress_bar.tsx rename to src/plugins/discover/public/application/components/sidebar/string_progress_bar.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/types.ts b/src/plugins/discover/public/application/components/sidebar/types.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/sidebar/types.ts rename to src/plugins/discover/public/application/components/sidebar/types.ts diff --git a/src/plugins/discover/public/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx similarity index 99% rename from src/plugins/discover/public/components/table/table.test.tsx rename to src/plugins/discover/public/application/components/table/table.test.tsx index 91e116c4c6696..04cf5d80da585 100644 --- a/src/plugins/discover/public/components/table/table.test.tsx +++ b/src/plugins/discover/public/application/components/table/table.test.tsx @@ -21,7 +21,7 @@ import { mount } from 'enzyme'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { DocViewTable } from './table'; -import { indexPatterns, IndexPattern } from '../../../../data/public'; +import { indexPatterns, IndexPattern } from '../../../../../data/public'; const indexPattern = { fields: [ diff --git a/src/plugins/discover/public/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table.tsx rename to src/plugins/discover/public/application/components/table/table.tsx diff --git a/src/plugins/discover/public/components/table/table_helper.test.ts b/src/plugins/discover/public/application/components/table/table_helper.test.ts similarity index 100% rename from src/plugins/discover/public/components/table/table_helper.test.ts rename to src/plugins/discover/public/application/components/table/table_helper.test.ts diff --git a/src/plugins/discover/public/components/table/table_helper.tsx b/src/plugins/discover/public/application/components/table/table_helper.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table_helper.tsx rename to src/plugins/discover/public/application/components/table/table_helper.tsx diff --git a/src/plugins/discover/public/components/table/table_row.tsx b/src/plugins/discover/public/application/components/table/table_row.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table_row.tsx rename to src/plugins/discover/public/application/components/table/table_row.tsx diff --git a/src/plugins/discover/public/components/table/table_row_btn_collapse.tsx b/src/plugins/discover/public/application/components/table/table_row_btn_collapse.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table_row_btn_collapse.tsx rename to src/plugins/discover/public/application/components/table/table_row_btn_collapse.tsx diff --git a/src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx b/src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx rename to src/plugins/discover/public/application/components/table/table_row_btn_filter_add.tsx diff --git a/src/plugins/discover/public/components/table/table_row_btn_filter_exists.tsx b/src/plugins/discover/public/application/components/table/table_row_btn_filter_exists.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table_row_btn_filter_exists.tsx rename to src/plugins/discover/public/application/components/table/table_row_btn_filter_exists.tsx diff --git a/src/plugins/discover/public/components/table/table_row_btn_filter_remove.tsx b/src/plugins/discover/public/application/components/table/table_row_btn_filter_remove.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table_row_btn_filter_remove.tsx rename to src/plugins/discover/public/application/components/table/table_row_btn_filter_remove.tsx diff --git a/src/plugins/discover/public/components/table/table_row_btn_toggle_column.tsx b/src/plugins/discover/public/application/components/table/table_row_btn_toggle_column.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table_row_btn_toggle_column.tsx rename to src/plugins/discover/public/application/components/table/table_row_btn_toggle_column.tsx diff --git a/src/plugins/discover/public/components/table/table_row_icon_no_mapping.tsx b/src/plugins/discover/public/application/components/table/table_row_icon_no_mapping.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table_row_icon_no_mapping.tsx rename to src/plugins/discover/public/application/components/table/table_row_icon_no_mapping.tsx diff --git a/src/plugins/discover/public/components/table/table_row_icon_underscore.tsx b/src/plugins/discover/public/application/components/table/table_row_icon_underscore.tsx similarity index 100% rename from src/plugins/discover/public/components/table/table_row_icon_underscore.tsx rename to src/plugins/discover/public/application/components/table/table_row_icon_underscore.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/__snapshots__/open_search_panel.test.js.snap b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/__snapshots__/open_search_panel.test.js.snap rename to src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap index 2878b11040cf3..be4cd712f7e31 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/__snapshots__/open_search_panel.test.js.snap +++ b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap @@ -19,7 +19,7 @@ exports[`render 1`] = `

@@ -30,7 +30,7 @@ exports[`render 1`] = ` noItemsMessage={ } @@ -62,7 +62,7 @@ exports[`render 1`] = ` > diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/open_search_panel.js b/src/plugins/discover/public/application/components/top_nav/open_search_panel.js similarity index 88% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/open_search_panel.js rename to src/plugins/discover/public/application/components/top_nav/open_search_panel.js index 747dd9abe2f2a..06267407dcb21 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/open_search_panel.js +++ b/src/plugins/discover/public/application/components/top_nav/open_search_panel.js @@ -32,7 +32,7 @@ import { EuiFlyoutBody, EuiTitle, } from '@elastic/eui'; -import { SavedObjectFinderUi } from '../../../../../../../../plugins/saved_objects/public'; +import { SavedObjectFinderUi } from '../../../../../saved_objects/public'; import { getServices } from '../../../kibana_services'; const SEARCH_OBJECT_TYPE = 'search'; @@ -48,7 +48,7 @@ export function OpenSearchPanel(props) {

@@ -58,7 +58,7 @@ export function OpenSearchPanel(props) { } @@ -66,7 +66,7 @@ export function OpenSearchPanel(props) { { type: SEARCH_OBJECT_TYPE, getIconForSavedObject: () => 'search', - name: i18n.translate('kbn.discover.savedSearch.savedObjectName', { + name: i18n.translate('discover.savedSearch.savedObjectName', { defaultMessage: 'Saved search', }), }, @@ -89,7 +89,7 @@ export function OpenSearchPanel(props) { href={`#/management/kibana/objects?_a=${rison.encode({ tab: SEARCH_OBJECT_TYPE })}`} > diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/open_search_panel.test.js b/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/open_search_panel.test.js rename to src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/show_open_search_panel.js b/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/top_nav/show_open_search_panel.js rename to src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js diff --git a/src/plugins/discover/public/doc_views/doc_views_helpers.tsx b/src/plugins/discover/public/application/doc_views/doc_views_helpers.tsx similarity index 100% rename from src/plugins/discover/public/doc_views/doc_views_helpers.tsx rename to src/plugins/discover/public/application/doc_views/doc_views_helpers.tsx diff --git a/src/plugins/discover/public/doc_views/doc_views_registry.ts b/src/plugins/discover/public/application/doc_views/doc_views_registry.ts similarity index 95% rename from src/plugins/discover/public/doc_views/doc_views_registry.ts rename to src/plugins/discover/public/application/doc_views/doc_views_registry.ts index 8f4518538be72..cdc22155f4710 100644 --- a/src/plugins/discover/public/doc_views/doc_views_registry.ts +++ b/src/plugins/discover/public/application/doc_views/doc_views_registry.ts @@ -25,9 +25,9 @@ export class DocViewsRegistry { private docViews: DocView[] = []; private angularInjectorGetter: (() => Promise) | null = null; - setAngularInjectorGetter(injectorGetter: () => Promise) { + setAngularInjectorGetter = (injectorGetter: () => Promise) => { this.angularInjectorGetter = injectorGetter; - } + }; /** * Extends and adds the given doc view to the registry array diff --git a/src/plugins/discover/public/doc_views/doc_views_types.ts b/src/plugins/discover/public/application/doc_views/doc_views_types.ts similarity index 97% rename from src/plugins/discover/public/doc_views/doc_views_types.ts rename to src/plugins/discover/public/application/doc_views/doc_views_types.ts index 0a4b5bb570bd7..0c86c4f812749 100644 --- a/src/plugins/discover/public/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/application/doc_views/doc_views_types.ts @@ -18,7 +18,7 @@ */ import { ComponentType } from 'react'; import { IScope } from 'angular'; -import { IndexPattern } from '../../../data/public'; +import { IndexPattern } from '../../../../data/public'; export interface AngularDirective { controller: (...injectedServices: any[]) => void; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/_embeddables.scss b/src/plugins/discover/public/application/embeddable/_embeddables.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/_embeddables.scss rename to src/plugins/discover/public/application/embeddable/_embeddables.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/_index.scss b/src/plugins/discover/public/application/embeddable/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/_index.scss rename to src/plugins/discover/public/application/embeddable/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants.ts b/src/plugins/discover/public/application/embeddable/constants.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants.ts rename to src/plugins/discover/public/application/embeddable/constants.ts diff --git a/src/plugins/discover/public/application/embeddable/index.ts b/src/plugins/discover/public/application/embeddable/index.ts new file mode 100644 index 0000000000000..b86a8daa119c5 --- /dev/null +++ b/src/plugins/discover/public/application/embeddable/index.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { SEARCH_EMBEDDABLE_TYPE } from './constants'; +export * from './types'; +export * from './search_embeddable_factory'; +export * from './search_embeddable'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable.ts b/src/plugins/discover/public/application/embeddable/search_embeddable.ts similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable.ts rename to src/plugins/discover/public/application/embeddable/search_embeddable.ts index f8e769d837447..b650672ccaea7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable.ts @@ -21,11 +21,8 @@ import _ from 'lodash'; import * as Rx from 'rxjs'; import { Subscription } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { - UiActionsStart, - APPLY_FILTER_TRIGGER, -} from '../../../../../../../plugins/ui_actions/public'; -import { RequestAdapter, Adapters } from '../../../../../../../plugins/inspector/public'; +import { UiActionsStart, APPLY_FILTER_TRIGGER } from '../../../../ui_actions/public'; +import { RequestAdapter, Adapters } from '../../../../inspector/public'; import { esFilters, Filter, @@ -34,8 +31,8 @@ import { getTime, Query, IFieldType, -} from '../../../../../../../plugins/data/public'; -import { Container, Embeddable } from '../../../../../../../plugins/embeddable/public'; +} from '../../../../data/public'; +import { Container, Embeddable } from '../../../../embeddable/public'; import * as columnActions from '../angular/doc_table/actions/columns'; import searchTemplate from './search_template.html'; import { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; @@ -49,7 +46,7 @@ import { ISearchSource, } from '../../kibana_services'; import { SEARCH_EMBEDDABLE_TYPE } from './constants'; -import { SavedSearch } from '../../../../../../../plugins/discover/public'; +import { SavedSearch } from '../..'; interface SearchScope extends ng.IScope { columns?: string[]; @@ -278,10 +275,10 @@ export class SearchEmbeddable extends Embeddable // Log request to inspector this.inspectorAdaptors.requests.reset(); - const title = i18n.translate('kbn.embeddable.inspectorRequestDataTitle', { + const title = i18n.translate('discover.embeddable.inspectorRequestDataTitle', { defaultMessage: 'Data', }); - const description = i18n.translate('kbn.embeddable.inspectorRequestDescription', { + const description = i18n.translate('discover.embeddable.inspectorRequestDescription', { defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', }); const inspectorRequest = this.inspectorAdaptors.requests.start(title, { description }); @@ -311,7 +308,7 @@ export class SearchEmbeddable extends Embeddable if (error.name === 'AbortError') return; getServices().toastNotifications.addError(error, { - title: i18n.translate('kbn.embeddable.errorTitle', { + title: i18n.translate('discover.embeddable.errorTitle', { defaultMessage: 'Error fetching data', }), }); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable_factory.ts b/src/plugins/discover/public/application/embeddable/search_embeddable_factory.ts similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable_factory.ts rename to src/plugins/discover/public/application/embeddable/search_embeddable_factory.ts index ad61984a52536..010a17e0d8d44 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_embeddable_factory.ts +++ b/src/plugins/discover/public/application/embeddable/search_embeddable_factory.ts @@ -25,9 +25,9 @@ import { EmbeddableFactoryDefinition, Container, ErrorEmbeddable, -} from '../../../../../../../plugins/embeddable/public'; +} from '../../../../embeddable/public'; -import { TimeRange } from '../../../../../../../plugins/data/public'; +import { TimeRange } from '../../../../data/public'; import { SearchEmbeddable } from './search_embeddable'; import { SearchInput, SearchOutput } from './types'; import { SEARCH_EMBEDDABLE_TYPE } from './constants'; @@ -43,7 +43,7 @@ export class SearchEmbeddableFactory private $injector: auto.IInjectorService | null; private getInjector: () => Promise | null; public readonly savedObjectMetaData = { - name: i18n.translate('kbn.discover.savedSearch.savedObjectName', { + name: i18n.translate('discover.savedSearch.savedObjectName', { defaultMessage: 'Saved search', }), type: 'search', @@ -67,7 +67,7 @@ export class SearchEmbeddableFactory }; public getDisplayName() { - return i18n.translate('kbn.embeddable.search.displayName', { + return i18n.translate('discover.embeddable.search.displayName', { defaultMessage: 'search', }); } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_template.html b/src/plugins/discover/public/application/embeddable/search_template.html similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/search_template.html rename to src/plugins/discover/public/application/embeddable/search_template.html diff --git a/src/plugins/discover/public/application/embeddable/types.ts b/src/plugins/discover/public/application/embeddable/types.ts new file mode 100644 index 0000000000000..80576eb4ed7cb --- /dev/null +++ b/src/plugins/discover/public/application/embeddable/types.ts @@ -0,0 +1,42 @@ +/* + * 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 { EmbeddableInput, EmbeddableOutput, IEmbeddable } from 'src/plugins/embeddable/public'; +import { SortOrder } from '../angular/doc_table/components/table_header/helpers'; +import { Filter, IIndexPattern, TimeRange, Query } from '../../../../data/public'; +import { SavedSearch } from '../..'; + +export interface SearchInput extends EmbeddableInput { + timeRange: TimeRange; + query?: Query; + filters?: Filter[]; + hidePanelTitles?: boolean; + columns?: string[]; + sort?: SortOrder[]; +} + +export interface SearchOutput extends EmbeddableOutput { + editUrl: string; + indexPatterns?: IIndexPattern[]; + editable: boolean; +} + +export interface ISearchEmbeddable extends IEmbeddable { + getSavedSearch(): SavedSearch; +} diff --git a/src/plugins/discover/public/application/helpers/breadcrumbs.ts b/src/plugins/discover/public/application/helpers/breadcrumbs.ts new file mode 100644 index 0000000000000..21e46b17225ea --- /dev/null +++ b/src/plugins/discover/public/application/helpers/breadcrumbs.ts @@ -0,0 +1,40 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export function getRootBreadcrumbs() { + return [ + { + text: i18n.translate('discover.rootBreadcrumb', { + defaultMessage: 'Discover', + }), + href: '#/discover', + }, + ]; +} + +export function getSavedSearchBreadcrumbs($route: any) { + return [ + ...getRootBreadcrumbs(), + { + text: $route.current.locals.savedObjects.savedSearch.id, + }, + ]; +} diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/get_index_pattern_id.ts b/src/plugins/discover/public/application/helpers/get_index_pattern_id.ts similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/get_index_pattern_id.ts rename to src/plugins/discover/public/application/helpers/get_index_pattern_id.ts index 8f4d1b28624a4..b78c43b9a74ac 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/get_index_pattern_id.ts +++ b/src/plugins/discover/public/application/helpers/get_index_pattern_id.ts @@ -17,7 +17,7 @@ * under the License. */ -import { IIndexPattern } from '../../../../../../../plugins/data/common/index_patterns'; +import { IIndexPattern } from '../../../../data/common/index_patterns'; export function findIndexPatternById( indexPatterns: IIndexPattern[], diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/index.ts b/src/plugins/discover/public/application/helpers/index.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/index.ts rename to src/plugins/discover/public/application/helpers/index.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/shorten_dotted_string.ts b/src/plugins/discover/public/application/helpers/shorten_dotted_string.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/helpers/shorten_dotted_string.ts rename to src/plugins/discover/public/application/helpers/shorten_dotted_string.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/_index.scss b/src/plugins/discover/public/application/index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/_index.scss rename to src/plugins/discover/public/application/index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/build_services.ts b/src/plugins/discover/public/build_services.ts similarity index 81% rename from src/legacy/core_plugins/kibana/public/discover/build_services.ts rename to src/plugins/discover/public/build_services.ts index b987129a9a7ed..6d3e0b55140ba 100644 --- a/src/legacy/core_plugins/kibana/public/discover/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -25,6 +25,7 @@ import { DocLinksStart, ToastsStart, IUiSettingsClient, + PluginInitializerContext, } from 'kibana/public'; import { FilterManager, @@ -32,17 +33,15 @@ import { IndexPatternsContract, DataPublicPluginStart, } from 'src/plugins/data/public'; +import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; +import { SharePluginStart } from 'src/plugins/share/public'; +import { ChartsPluginStart } from 'src/plugins/charts/public'; +import { VisualizationsStart } from 'src/plugins/visualizations/public'; +import { SavedObjectKibanaServices } from 'src/plugins/saved_objects/public'; import { DiscoverStartPlugins } from './plugin'; -import { SharePluginStart } from '../../../../../plugins/share/public'; -import { ChartsPluginStart } from '../../../../../plugins/charts/public'; -import { VisualizationsStart } from '../../../../../plugins/visualizations/public'; -import { - createSavedSearchesLoader, - DocViewerComponent, - SavedSearch, -} from '../../../../../plugins/discover/public'; -import { SavedObjectKibanaServices } from '../../../../../plugins/saved_objects/public'; +import { createSavedSearchesLoader, SavedSearch } from './saved_searches'; +import { getHistory } from './kibana_services'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -51,14 +50,13 @@ export interface DiscoverServices { core: CoreStart; data: DataPublicPluginStart; docLinks: DocLinksStart; - DocViewer: DocViewerComponent; history: () => History; theme: ChartsPluginStart['theme']; filterManager: FilterManager; indexPatterns: IndexPatternsContract; - inspector: unknown; + inspector: InspectorPublicPluginStart; metadata: { branch: string }; - share: SharePluginStart; + share?: SharePluginStart; timefilter: TimefilterContract; toastNotifications: ToastsStart; getSavedSearchById: (id: string) => Promise; @@ -70,7 +68,7 @@ export interface DiscoverServices { export async function buildServices( core: CoreStart, plugins: DiscoverStartPlugins, - getHistory: () => History + context: PluginInitializerContext ): Promise { const services: SavedObjectKibanaServices = { savedObjectsClient: core.savedObjects.client, @@ -88,7 +86,6 @@ export async function buildServices( core, data: plugins.data, docLinks: core.docLinks, - DocViewer: plugins.discover.docViews.DocViewer, theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, getSavedSearchById: async (id: string) => savedObjectService.get(id), @@ -96,8 +93,9 @@ export async function buildServices( history: getHistory, indexPatterns: plugins.data.indexPatterns, inspector: plugins.inspector, - // @ts-ignore - metadata: core.injectedMetadata.getLegacyMetadata(), + metadata: { + branch: context.env.packageInfo.branch, + }, share: plugins.share, timefilter: plugins.data.query.timefilter.timefilter, toastNotifications: core.notifications.toasts, diff --git a/src/plugins/discover/public/components/_index.scss b/src/plugins/discover/public/components/_index.scss deleted file mode 100644 index ff50d4b5dca93..0000000000000 --- a/src/plugins/discover/public/components/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'doc_viewer/index'; diff --git a/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx deleted file mode 100644 index 792d9c44400d7..0000000000000 --- a/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import React from 'react'; -import { EuiTabbedContent } from '@elastic/eui'; -import { getDocViewsRegistry } from '../../services'; -import { DocViewerTab } from './doc_viewer_tab'; -import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; - -/** - * Rendering tabs with different views of 1 Elasticsearch hit in Discover. - * The tabs are provided by the `docs_views` registry. - * A view can contain a React `component`, or any JS framework by using - * a `render` function. - */ -export function DocViewer(renderProps: DocViewRenderProps) { - const docViewsRegistry = getDocViewsRegistry(); - const tabs = docViewsRegistry - .getDocViewsSorted(renderProps.hit) - .map(({ title, render, component }: DocView, idx: number) => { - return { - id: title, - name: title, - content: ( - - ), - }; - }); - - if (!tabs.length) { - // There there's a minimum of 2 tabs active in Discover. - // This condition takes care of unit tests with 0 tabs. - return null; - } - - return ( -
- -
- ); -} diff --git a/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts similarity index 77% rename from src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts rename to src/plugins/discover/public/get_inner_angular.ts index 6366466103652..e7813c43383f9 100644 --- a/src/legacy/core_plugins/kibana/public/discover/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -25,27 +25,26 @@ import angular from 'angular'; import 'angular-sanitize'; import { EuiIcon } from '@elastic/eui'; import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; -import { CoreStart, LegacyCoreStart } from 'kibana/public'; -import { DataPublicPluginStart } from '../../../../../plugins/data/public'; -import { Storage } from '../../../../../plugins/kibana_utils/public'; -import { NavigationPublicPluginStart as NavigationStart } from '../../../../../plugins/navigation/public'; -import { createDocTableDirective } from './np_ready/angular/doc_table'; -import { createTableHeaderDirective } from './np_ready/angular/doc_table/components/table_header'; +import { CoreStart, PluginInitializerContext } from 'kibana/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { Storage } from '../../kibana_utils/public'; +import { NavigationPublicPluginStart as NavigationStart } from '../../navigation/public'; +import { createDocTableDirective } from './application/angular/doc_table'; +import { createTableHeaderDirective } from './application/angular/doc_table/components/table_header'; import { createToolBarPagerButtonsDirective, createToolBarPagerTextDirective, -} from './np_ready/angular/doc_table/components/pager'; -import { createTableRowDirective } from './np_ready/angular/doc_table/components/table_row'; -import { createPagerFactory } from './np_ready/angular/doc_table/lib/pager/pager_factory'; -import { createInfiniteScrollDirective } from './np_ready/angular/doc_table/infinite_scroll'; -import { createDocViewerDirective } from './np_ready/angular/doc_viewer'; -import { CollapsibleSidebarProvider } from './np_ready/angular/directives/collapsible_sidebar/collapsible_sidebar'; -import { DiscoverStartPlugins } from './plugin'; +} from './application/angular/doc_table/components/pager'; +import { createTableRowDirective } from './application/angular/doc_table/components/table_row'; +import { createPagerFactory } from './application/angular/doc_table/lib/pager/pager_factory'; +import { createInfiniteScrollDirective } from './application/angular/doc_table/infinite_scroll'; +import { createDocViewerDirective } from './application/angular/doc_viewer'; +import { CollapsibleSidebarProvider } from './application/angular/directives/collapsible_sidebar/collapsible_sidebar'; // @ts-ignore -import { FixedScrollProvider } from './np_ready/angular/directives/fixed_scroll'; +import { FixedScrollProvider } from './application/angular/directives/fixed_scroll'; // @ts-ignore -import { DebounceProviderTimeout } from './np_ready/angular/directives/debounce/debounce'; -import { createRenderCompleteDirective } from './np_ready/angular/directives/render_complete'; +import { DebounceProviderTimeout } from './application/angular/directives/debounce/debounce'; +import { createRenderCompleteDirective } from './application/angular/directives/render_complete'; import { initAngularBootstrap, configureAppAngularModule, @@ -56,17 +55,23 @@ import { watchMultiDecorator, createTopNavDirective, createTopNavHelper, -} from '../../../../../plugins/kibana_legacy/public'; -import { createDiscoverSidebarDirective } from './np_ready/components/sidebar'; +} from '../../kibana_legacy/public'; +import { createDiscoverSidebarDirective } from './application/components/sidebar'; +import { DiscoverStartPlugins } from './plugin'; /** * returns the main inner angular module, it contains all the parts of Angular Discover * needs to render, so in the end the current 'kibana' angular module is no longer necessary */ -export function getInnerAngularModule(name: string, core: CoreStart, deps: DiscoverStartPlugins) { +export function getInnerAngularModule( + name: string, + core: CoreStart, + deps: DiscoverStartPlugins, + context: PluginInitializerContext +) { initAngularBootstrap(); const module = initializeInnerAngularModule(name, core, deps.navigation, deps.data); - configureAppAngularModule(module, core as LegacyCoreStart, true); + configureAppAngularModule(module, { core, env: context.env }, true); return module; } @@ -76,10 +81,11 @@ export function getInnerAngularModule(name: string, core: CoreStart, deps: Disco export function getInnerAngularModuleEmbeddable( name: string, core: CoreStart, - deps: DiscoverStartPlugins + deps: DiscoverStartPlugins, + context: PluginInitializerContext ) { const module = initializeInnerAngularModule(name, core, deps.navigation, deps.data, true); - configureAppAngularModule(module, core as LegacyCoreStart, true); + configureAppAngularModule(module, { core, env: context.env }, true); return module; } @@ -188,7 +194,8 @@ function createElasticSearchModule(data: DataPublicPluginStart) { angular .module('discoverEs', []) // Elasticsearch client used for requesting data. Connects to the /elasticsearch proxy - .service('es', () => { + // have to be written as function expression, because it's not compiled in dev mode + .service('es', function() { return data.search.__LEGACY.esClient; }); } diff --git a/src/plugins/discover/public/helpers/index.ts b/src/plugins/discover/public/helpers/index.ts deleted file mode 100644 index 7196c96989e97..0000000000000 --- a/src/plugins/discover/public/helpers/index.ts +++ /dev/null @@ -1,20 +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. - */ - -export { shortenDottedString } from './shorten_dotted_string'; diff --git a/src/plugins/discover/public/helpers/shorten_dotted_string.ts b/src/plugins/discover/public/helpers/shorten_dotted_string.ts deleted file mode 100644 index 9d78a96784339..0000000000000 --- a/src/plugins/discover/public/helpers/shorten_dotted_string.ts +++ /dev/null @@ -1,26 +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. - */ - -const DOT_PREFIX_RE = /(.).+?\./g; - -/** - * Convert a dot.notated.string into a short - * version (d.n.string) - */ -export const shortenDottedString = (input: string) => input.replace(DOT_PREFIX_RE, '$1.'); diff --git a/src/plugins/discover/public/index.scss b/src/plugins/discover/public/index.scss deleted file mode 100644 index 841415620d691..0000000000000 --- a/src/plugins/discover/public/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'components/index'; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index dbc361ee59f49..359d91325f064 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -17,18 +17,13 @@ * under the License. */ +import { PluginInitializerContext } from 'kibana/public'; import { DiscoverPlugin } from './plugin'; export { DiscoverSetup, DiscoverStart } from './plugin'; -export { DocViewTable } from './components/table/table'; -export { JsonCodeBlock } from './components/json_code_block/json_code_block'; -export { DocViewInput, DocViewInputFn, DocViewerComponent } from './doc_views/doc_views_types'; -export { FieldName } from './components/field_name/field_name'; -export * from './doc_views/doc_views_types'; - -export function plugin() { - return new DiscoverPlugin(); +export function plugin(initializerContext: PluginInitializerContext) { + return new DiscoverPlugin(initializerContext); } -export { createSavedSearchesLoader } from './saved_searches/saved_searches'; -export { SavedSearchLoader, SavedSearch } from './saved_searches/types'; +export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; +export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/plugins/discover/public/kibana_services.ts similarity index 79% rename from src/legacy/core_plugins/kibana/public/discover/kibana_services.ts rename to src/plugins/discover/public/kibana_services.ts index 77664e87a3279..7b61bae0d0df5 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/plugins/discover/public/kibana_services.ts @@ -18,8 +18,9 @@ */ import { createHashHistory } from 'history'; import { DiscoverServices } from './build_services'; -import { createGetterSetter } from '../../../../../plugins/kibana_utils/public'; -import { search } from '../../../../../plugins/data/public'; +import { createGetterSetter } from '../../kibana_utils/public'; +import { search } from '../../data/public'; +import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; let angularModule: any = null; let services: DiscoverServices | null = null; @@ -53,18 +54,18 @@ export const [getUrlTracker, setUrlTracker] = createGetterSetter<{ setTrackedUrl: (url: string) => void; }>('urlTracker'); +export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( + 'DocViewsRegistry' +); + /** * Makes sure discover and context are using one instance of history */ export const getHistory = _.once(() => createHashHistory()); export const { getRequestInspectorStats, getResponseInspectorStats, tabifyAggResponse } = search; -export { unhashUrl, redirectWhenMissing } from '../../../../../plugins/kibana_utils/public'; -export { - formatMsg, - formatStack, - subscribeWithScope, -} from '../../../../../plugins/kibana_legacy/public'; +export { unhashUrl, redirectWhenMissing } from '../../kibana_utils/public'; +export { formatMsg, formatStack, subscribeWithScope } from '../../kibana_legacy/public'; // EXPORT types export { @@ -76,4 +77,4 @@ export { ISearchSource, EsQuerySortValue, SortDirection, -} from '../../../../../plugins/data/public'; +} from '../../data/public'; diff --git a/src/plugins/discover/public/mocks.ts b/src/plugins/discover/public/mocks.ts index 218c59b5db07b..dd7da5e8bc254 100644 --- a/src/plugins/discover/public/mocks.ts +++ b/src/plugins/discover/public/mocks.ts @@ -26,7 +26,6 @@ const createSetupContract = (): Setup => { const setupContract: Setup = { docViews: { addDocView: jest.fn(), - setAngularInjectorGetter: jest.fn(), }, }; return setupContract; @@ -34,9 +33,6 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { const startContract: Start = { - docViews: { - DocViewer: jest.fn(() => null), - }, savedSearches: { createLoader: jest.fn(), }, diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index aa54823e6ec4d..807365cb26dc0 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -17,20 +17,46 @@ * under the License. */ -import React from 'react'; import { i18n } from '@kbn/i18n'; -import { auto } from 'angular'; -import { CoreSetup, Plugin } from 'kibana/public'; +import angular, { auto } from 'angular'; +import { BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; + +import { + AppMountParameters, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from 'kibana/public'; +import { UiActionsStart, UiActionsSetup } from 'src/plugins/ui_actions/public'; +import { EmbeddableStart, EmbeddableSetup } from 'src/plugins/embeddable/public'; +import { ChartsPluginStart } from 'src/plugins/charts/public'; +import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; +import { SharePluginStart } from 'src/plugins/share/public'; +import { VisualizationsStart, VisualizationsSetup } from 'src/plugins/visualizations/public'; +import { KibanaLegacySetup, AngularRenderedAppUpdater } from 'src/plugins/kibana_legacy/public'; +import { HomePublicPluginSetup } from 'src/plugins/home/public'; +import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; +import { DataPublicPluginStart, DataPublicPluginSetup, esFilters } from '../../data/public'; import { SavedObjectLoader, SavedObjectKibanaServices } from '../../saved_objects/public'; -import { DocViewInput, DocViewInputFn, DocViewRenderProps } from './doc_views/doc_views_types'; -import { DocViewsRegistry } from './doc_views/doc_views_registry'; -import { DocViewTable } from './components/table/table'; -import { JsonCodeBlock } from './components/json_code_block/json_code_block'; -import { DocViewer } from './components/doc_viewer/doc_viewer'; -import { setDocViewsRegistry } from './services'; -import { createSavedSearchesLoader } from './saved_searches'; +import { createKbnUrlTracker } from '../../kibana_utils/public'; -import './index.scss'; +import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; +import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; +import { DocViewTable } from './application/components/table/table'; +import { JsonCodeBlock } from './application/components/json_code_block/json_code_block'; +import { + getHistory, + setDocViewsRegistry, + setUrlTracker, + setAngularModule, + setServices, +} from './kibana_services'; +import { createSavedSearchesLoader } from './saved_searches'; +import { getInnerAngularModuleEmbeddable, getInnerAngularModule } from './get_inner_angular'; +import { registerFeature } from './register_feature'; +import { buildServices } from './build_services'; /** * @public @@ -43,27 +69,10 @@ export interface DiscoverSetup { * @param docViewRaw */ addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; - /** - * Set the angular injector for bootstrapping angular doc views. This is only exposed temporarily to aid - * migration to the new platform and will be removed soon. - * @deprecated - * @param injectorGetter - */ - setAngularInjectorGetter(injectorGetter: () => Promise): void; }; } -/** - * @public - */ + export interface DiscoverStart { - docViews: { - /** - * Component rendering all the doc views for a given document. - * This is only exposed temporarily to aid migration to the new platform and will be removed soon. - * @deprecated - */ - DocViewer: React.ComponentType; - }; savedSearches: { /** * Create a {@link SavedObjectLoader | loader} to handle the saved searches type. @@ -73,15 +82,59 @@ export interface DiscoverStart { }; } +/** + * @internal + */ +export interface DiscoverSetupPlugins { + uiActions: UiActionsSetup; + embeddable: EmbeddableSetup; + kibanaLegacy: KibanaLegacySetup; + home?: HomePublicPluginSetup; + visualizations: VisualizationsSetup; + data: DataPublicPluginSetup; +} + +/** + * @internal + */ +export interface DiscoverStartPlugins { + uiActions: UiActionsStart; + embeddable: EmbeddableStart; + navigation: NavigationStart; + charts: ChartsPluginStart; + data: DataPublicPluginStart; + share?: SharePluginStart; + inspector: InspectorPublicPluginStart; + visualizations: VisualizationsStart; +} + +const innerAngularName = 'app/discover'; +const embeddableAngularName = 'app/discoverEmbeddable'; + /** * Contains Discover, one of the oldest parts of Kibana * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular * Discover provides embeddables, those contain a slimmer Angular */ -export class DiscoverPlugin implements Plugin { +export class DiscoverPlugin + implements Plugin { + constructor(private readonly initializerContext: PluginInitializerContext) {} + + private appStateUpdater = new BehaviorSubject(() => ({})); private docViewsRegistry: DocViewsRegistry | null = null; + private embeddableInjector: auto.IInjectorService | null = null; + private stopUrlTracking: (() => void) | undefined = undefined; + private servicesInitialized: boolean = false; + private innerAngularInitialized: boolean = false; - setup(core: CoreSetup): DiscoverSetup { + /** + * why are those functions public? they are needed for some mocha tests + * can be removed once all is Jest + */ + public initializeInnerAngular?: () => void; + public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; + + setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { this.docViewsRegistry = new DocViewsRegistry(); setDocViewsRegistry(this.docViewsRegistry); this.docViewsRegistry.addDocView({ @@ -99,24 +152,171 @@ export class DiscoverPlugin implements Plugin { component: JsonCodeBlock, }); + const { + appMounted, + appUnMounted, + stop: stopUrlTracker, + setActiveUrl: setTrackedUrl, + } = createKbnUrlTracker({ + // we pass getter here instead of plain `history`, + // so history is lazily created (when app is mounted) + // this prevents redundant `#` when not in discover app + getHistory, + baseUrl: core.http.basePath.prepend('/app/kibana'), + defaultSubUrl: '#/discover', + storageKey: `lastUrl:${core.http.basePath.get()}:discover`, + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + kbnUrlKey: '_g', + stateUpdate$: plugins.data.query.state$.pipe( + filter( + ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) + ), + map(({ state }) => ({ + ...state, + filters: state.filters?.filter(esFilters.isFilterPinned), + })) + ), + }, + ], + }); + setUrlTracker({ setTrackedUrl }); + this.stopUrlTracking = () => { + stopUrlTracker(); + }; + + this.docViewsRegistry.setAngularInjectorGetter(this.getEmbeddableInjector); + plugins.kibanaLegacy.registerLegacyApp({ + id: 'discover', + title: 'Discover', + updater$: this.appStateUpdater.asObservable(), + navLinkId: 'kibana:discover', + order: -1004, + euiIconType: 'discoverApp', + mount: async (params: AppMountParameters) => { + if (!this.initializeServices) { + throw Error('Discover plugin method initializeServices is undefined'); + } + if (!this.initializeInnerAngular) { + throw Error('Discover plugin method initializeInnerAngular is undefined'); + } + appMounted(); + const { + plugins: { data: dataStart }, + } = await this.initializeServices(); + await this.initializeInnerAngular(); + + // make sure the index pattern list is up to date + await dataStart.indexPatterns.clearCache(); + const { renderApp } = await import('./application/application'); + const unmount = await renderApp(innerAngularName, params.element); + return () => { + unmount(); + appUnMounted(); + }; + }, + }); + + if (plugins.home) { + registerFeature(plugins.home); + } + + this.registerEmbeddable(core, plugins); + return { docViews: { addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), - setAngularInjectorGetter: this.docViewsRegistry.setAngularInjectorGetter.bind( - this.docViewsRegistry - ), }, }; } - start() { + start(core: CoreStart, plugins: DiscoverStartPlugins) { + // we need to register the application service at setup, but to render it + // there are some start dependencies necessary, for this reason + // initializeInnerAngular + initializeServices are assigned at start and used + // when the application/embeddable is mounted + this.initializeInnerAngular = async () => { + if (this.innerAngularInitialized) { + return; + } + // this is used by application mount and tests + const module = getInnerAngularModule( + innerAngularName, + core, + plugins, + this.initializerContext + ); + setAngularModule(module); + this.innerAngularInitialized = true; + }; + + this.initializeServices = async () => { + if (this.servicesInitialized) { + return { core, plugins }; + } + const services = await buildServices(core, plugins, this.initializerContext); + setServices(services); + this.servicesInitialized = true; + + return { core, plugins }; + }; + return { - docViews: { - DocViewer, - }, savedSearches: { createLoader: createSavedSearchesLoader, }, }; } + + stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } + + /** + * register embeddable with a slimmer embeddable version of inner angular + */ + private async registerEmbeddable( + core: CoreSetup, + plugins: DiscoverSetupPlugins + ) { + const { SearchEmbeddableFactory } = await import('./application/embeddable'); + + if (!this.getEmbeddableInjector) { + throw Error('Discover plugin method getEmbeddableInjector is undefined'); + } + + const getStartServices = async () => { + const [coreStart, deps] = await core.getStartServices(); + return { + executeTriggerActions: deps.uiActions.executeTriggerActions, + isEditable: () => coreStart.application.capabilities.discover.save as boolean, + }; + }; + + const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector); + plugins.embeddable.registerEmbeddableFactory(factory.type, factory); + } + + private getEmbeddableInjector = async () => { + if (!this.embeddableInjector) { + if (!this.initializeServices) { + throw Error('Discover plugin getEmbeddableInjector: initializeServices is undefined'); + } + const { core, plugins } = await this.initializeServices(); + getInnerAngularModuleEmbeddable( + embeddableAngularName, + core, + plugins, + this.initializerContext + ); + const mountpoint = document.createElement('div'); + this.embeddableInjector = angular.bootstrap(mountpoint, [embeddableAngularName]); + } + + return this.embeddableInjector; + }; } diff --git a/src/plugins/discover/public/register_feature.ts b/src/plugins/discover/public/register_feature.ts new file mode 100644 index 0000000000000..764855634f5c5 --- /dev/null +++ b/src/plugins/discover/public/register_feature.ts @@ -0,0 +1,36 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../home/public'; + +export function registerFeature(home: HomePublicPluginSetup) { + home.featureCatalogue.register({ + id: 'discover', + title: i18n.translate('discover.discoverTitle', { + defaultMessage: 'Discover', + }), + description: i18n.translate('discover.discoverDescription', { + defaultMessage: 'Interactively explore your data by querying and filtering raw documents.', + }), + icon: 'discoverApp', + path: '/app/kibana#/discover', + showOnHomePage: true, + category: FeatureCatalogueCategory.DATA, + }); +} diff --git a/src/plugins/discover/public/saved_searches/index.ts b/src/plugins/discover/public/saved_searches/index.ts index 24832df308a3e..2ba7298b0f327 100644 --- a/src/plugins/discover/public/saved_searches/index.ts +++ b/src/plugins/discover/public/saved_searches/index.ts @@ -17,4 +17,5 @@ * under the License. */ -export * from './saved_searches'; +export { createSavedSearchesLoader } from './saved_searches'; +export { SavedSearch, SavedSearchLoader } from './types'; diff --git a/src/plugins/discover/public/services.ts b/src/plugins/discover/public/services.ts deleted file mode 100644 index 37e2144800ea1..0000000000000 --- a/src/plugins/discover/public/services.ts +++ /dev/null @@ -1,25 +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 { createGetterSetter } from '../../kibana_utils/public'; -import { DocViewsRegistry } from './doc_views/doc_views_registry'; - -export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( - 'DocViewsRegistry' -); diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index c8c4f0b95c458..33cf210763b10 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 5ee66f9d19ac0..e61ad2a6eefed 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 0abbc25ff49a6..d57867900c24b 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 a135484ff61be..9c544e86e189a 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.test.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts deleted file mode 100644 index ddd84b0544345..0000000000000 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts +++ /dev/null @@ -1,536 +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 './embeddable'; -import { EmbeddableInput } from './i_embeddable'; -import { ViewMode } from '../types'; -import { EmbeddableActionStorage, SerializedEvent } from './embeddable_action_storage'; -import { of } from '../../../../kibana_utils/public'; - -class TestEmbeddable extends Embeddable { - public readonly type = 'test'; - constructor() { - super({ id: 'test', viewMode: ViewMode.VIEW }, {}); - } - reload() {} -} - -describe('EmbeddableActionStorage', () => { - describe('.create()', () => { - test('method exists', () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - expect(typeof storage.create).toBe('function'); - }); - - test('can add event to embeddable', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { - eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - - const events1 = embeddable.getInput().events || []; - expect(events1).toEqual([]); - - await storage.create(event); - - const events2 = embeddable.getInput().events || []; - expect(events2).toEqual([event]); - }); - - test('can create multiple events', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event1: SerializedEvent = { - eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - const event2: SerializedEvent = { - eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - const event3: SerializedEvent = { - eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - - const events1 = embeddable.getInput().events || []; - expect(events1).toEqual([]); - - await storage.create(event1); - - const events2 = embeddable.getInput().events || []; - expect(events2).toEqual([event1]); - - await storage.create(event2); - await storage.create(event3); - - const events3 = embeddable.getInput().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 = { - eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - - await storage.create(event); - const [, error] = await of(storage.create(event)); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[EEXIST]: Event with [eventId = EVENT_ID] already exists on [embeddable.id = test, embeddable.title = undefined]."` - ); - }); - }); - - describe('.update()', () => { - test('method exists', () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - expect(typeof storage.update).toBe('function'); - }); - - test('can update an existing event', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event1: SerializedEvent = { - eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', - action: { - name: 'foo', - } as any, - }; - const event2: SerializedEvent = { - eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', - action: { - name: 'bar', - } as any, - }; - - await storage.create(event1); - await storage.update(event2); - - const events = embeddable.getInput().events || []; - expect(events).toEqual([event2]); - }); - - test('updates event in place of the old event', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event1: SerializedEvent = { - eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', - action: { - name: 'foo', - } as any, - }; - const event2: SerializedEvent = { - eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', - action: { - name: 'bar', - } as any, - }; - const event22: SerializedEvent = { - eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', - action: { - name: 'baz', - } as any, - }; - const event3: SerializedEvent = { - eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', - action: { - name: 'qux', - } as any, - }; - - await storage.create(event1); - await storage.create(event2); - await storage.create(event3); - - const events1 = embeddable.getInput().events || []; - expect(events1).toEqual([event1, event2, event3]); - - await storage.update(event22); - - const events2 = embeddable.getInput().events || []; - expect(events2).toEqual([event1, event22, event3]); - - await storage.update(event2); - - const events3 = embeddable.getInput().events || []; - expect(events3).toEqual([event1, event2, event3]); - }); - - test('throws when updating event, but storage is empty', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event: SerializedEvent = { - eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - - const [, error] = await of(storage.update(event)); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = EVENT_ID] could not be updated as it does not exist in [embeddable.id = test, embeddable.title = undefined]."` - ); - }); - - test('throws when updating event with ID that is not stored', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event1: SerializedEvent = { - eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - const event2: SerializedEvent = { - eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - - await storage.create(event1); - const [, error] = await of(storage.update(event2)); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = EVENT_ID2] could not be updated as it does not exist in [embeddable.id = test, embeddable.title = undefined]."` - ); - }); - }); - - describe('.remove()', () => { - test('method exists', () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - expect(typeof storage.remove).toBe('function'); - }); - - test('can remove existing event', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event: SerializedEvent = { - eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - - await storage.create(event); - await storage.remove(event.eventId); - - const events = embeddable.getInput().events || []; - expect(events).toEqual([]); - }); - - test('removes correct events in a list of events', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event1: SerializedEvent = { - eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', - action: { - name: 'foo', - } as any, - }; - const event2: SerializedEvent = { - eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', - action: { - name: 'bar', - } as any, - }; - const event3: SerializedEvent = { - eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', - action: { - name: 'qux', - } as any, - }; - - await storage.create(event1); - await storage.create(event2); - await storage.create(event3); - - const events1 = embeddable.getInput().events || []; - expect(events1).toEqual([event1, event2, event3]); - - await storage.remove(event2.eventId); - - const events2 = embeddable.getInput().events || []; - expect(events2).toEqual([event1, event3]); - - await storage.remove(event3.eventId); - - const events3 = embeddable.getInput().events || []; - expect(events3).toEqual([event1]); - - await storage.remove(event1.eventId); - - const events4 = embeddable.getInput().events || []; - expect(events4).toEqual([]); - }); - - test('throws when removing an event from an empty storage', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const [, error] = await of(storage.remove('EVENT_ID')); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = EVENT_ID] could not be removed as it does not exist in [embeddable.id = test, embeddable.title = undefined]."` - ); - }); - - test('throws when removing with ID that does not exist in storage', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event: SerializedEvent = { - eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - - await storage.create(event); - const [, error] = await of(storage.remove('WRONG_ID')); - await storage.remove(event.eventId); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = WRONG_ID] could not be removed as it does not exist in [embeddable.id = test, embeddable.title = undefined]."` - ); - }); - }); - - describe('.read()', () => { - test('method exists', () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - expect(typeof storage.read).toBe('function'); - }); - - test('can read an existing event out of storage', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event: SerializedEvent = { - eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - - await storage.create(event); - const event2 = await storage.read(event.eventId); - - expect(event2).toEqual(event); - }); - - test('throws when reading from empty storage', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const [, error] = await of(storage.read('EVENT_ID')); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = EVENT_ID] could not be found in [embeddable.id = test, embeddable.title = undefined]."` - ); - }); - - test('throws when reading event with ID not existing in storage', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event: SerializedEvent = { - eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', - action: {} as any, - }; - - await storage.create(event); - const [, error] = await of(storage.read('WRONG_ID')); - - expect(error).toBeInstanceOf(Error); - expect(error.message).toMatchInlineSnapshot( - `"[ENOENT]: Event with [eventId = WRONG_ID] could not be found in [embeddable.id = test, embeddable.title = undefined]."` - ); - }); - - test('returns correct event when multiple events are stored', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event1: SerializedEvent = { - eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', - action: {} as any, - }; - const event2: SerializedEvent = { - eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID2', - action: {} as any, - }; - const event3: SerializedEvent = { - eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID3', - action: {} as any, - }; - - await storage.create(event1); - await storage.create(event2); - await storage.create(event3); - - const event12 = await storage.read(event1.eventId); - const event22 = await storage.read(event2.eventId); - const event32 = await storage.read(event3.eventId); - - expect(event12).toEqual(event1); - expect(event22).toEqual(event2); - expect(event32).toEqual(event3); - - expect(event12).not.toEqual(event2); - }); - }); - - describe('.count()', () => { - test('method exists', () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - expect(typeof storage.count).toBe('function'); - }); - - test('returns 0 when storage is empty', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const count = await storage.count(); - - expect(count).toBe(0); - }); - - test('returns correct number of events in storage', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - expect(await storage.count()).toBe(0); - - await storage.create({ - eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', - action: {} as any, - }); - - expect(await storage.count()).toBe(1); - - await storage.create({ - eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', - action: {} as any, - }); - - expect(await storage.count()).toBe(2); - - await storage.remove('EVENT_ID1'); - - expect(await storage.count()).toBe(1); - - await storage.remove('EVENT_ID2'); - - expect(await storage.count()).toBe(0); - }); - }); - - describe('.list()', () => { - test('method exists', () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - expect(typeof storage.list).toBe('function'); - }); - - test('returns empty array when storage is empty', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const list = await storage.list(); - - expect(list).toEqual([]); - }); - - test('returns correct list of events in storage', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - - const event1: SerializedEvent = { - eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', - action: {} as any, - }; - - const event2: SerializedEvent = { - eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', - action: {} as any, - }; - - expect(await storage.list()).toEqual([]); - - await storage.create(event1); - - expect(await storage.list()).toEqual([event1]); - - await storage.create(event2); - - expect(await storage.list()).toEqual([event1, event2]); - - await storage.remove('EVENT_ID1'); - - expect(await storage.list()).toEqual([event2]); - - await storage.remove('EVENT_ID2'); - - expect(await storage.list()).toEqual([]); - }); - }); -}); 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 520f92840c5f9..0000000000000 --- 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 9a3e49e497962..c16698a5f8637 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 49b6d7803a200..9dd4c74c624d9 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 c43359382a33d..36ddfb49b0312 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 c0e43c0538833..36957c3b79491 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 d04f35715537c..ae9645767b267 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 ee7948f3d6a4a..a6d4128f3f106 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 99516a1d21d6f..35a10ed848e83 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 65b15f3a7614f..f5487c381cfcb 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 01d6b8a603db6..fc1c086e817c9 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 c57db6029ec2e..ee3fbd7a7b0b0 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -37,7 +37,7 @@ export { ReactExpressionRendererProps, ReactExpressionRendererType, } from './react_expression_renderer'; -export { ExpressionRenderHandler } from './render'; +export { ExpressionRenderHandler, ExpressionRendererEvent } from './render'; export { AnyExpressionFunctionDefinition, AnyExpressionTypeDefinition, @@ -78,7 +78,7 @@ export { ExpressionValueRender, ExpressionValueSearchContext, ExpressionValueUnboxed, - Filter, + ExpressionValueFilter, Font, FontLabel, FontStyle, diff --git a/src/plugins/expressions/public/react_expression_renderer.test.tsx b/src/plugins/expressions/public/react_expression_renderer.test.tsx index 65cc5fc1569cb..caa9bc68dffb8 100644 --- a/src/plugins/expressions/public/react_expression_renderer.test.tsx +++ b/src/plugins/expressions/public/react_expression_renderer.test.tsx @@ -26,6 +26,7 @@ import { ExpressionLoader } from './loader'; import { mount } from 'enzyme'; import { EuiProgress } from '@elastic/eui'; import { RenderErrorHandlerFnType } from './types'; +import { ExpressionRendererEvent } from './render'; jest.mock('./loader', () => { return { @@ -135,4 +136,44 @@ describe('ExpressionRenderer', () => { expect(instance.find(EuiProgress)).toHaveLength(0); expect(instance.find('[data-test-subj="custom-error"]')).toHaveLength(0); }); + + it('should fire onEvent prop on every events$ observable emission in loader', () => { + const dataSubject = new Subject(); + const data$ = dataSubject.asObservable().pipe(share()); + const renderSubject = new Subject(); + const render$ = renderSubject.asObservable().pipe(share()); + const loadingSubject = new Subject(); + const loading$ = loadingSubject.asObservable().pipe(share()); + const eventsSubject = new Subject(); + const events$ = eventsSubject.asObservable().pipe(share()); + + const onEvent = jest.fn(); + const event: ExpressionRendererEvent = { + name: 'foo', + data: { + bar: 'baz', + }, + }; + + (ExpressionLoader as jest.Mock).mockImplementation(() => { + return { + render$, + data$, + loading$, + events$, + update: jest.fn(), + }; + }); + + mount(); + + expect(onEvent).toHaveBeenCalledTimes(0); + + act(() => { + eventsSubject.next(event); + }); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0][0]).toBe(event); + }); }); diff --git a/src/plugins/expressions/public/react_expression_renderer.tsx b/src/plugins/expressions/public/react_expression_renderer.tsx index 2c99f173c9f33..9e237d36ef627 100644 --- a/src/plugins/expressions/public/react_expression_renderer.tsx +++ b/src/plugins/expressions/public/react_expression_renderer.tsx @@ -27,6 +27,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { IExpressionLoaderParams, RenderError } from './types'; import { ExpressionAstExpression, IInterpreterRenderHandlers } from '../common'; import { ExpressionLoader } from './loader'; +import { ExpressionRendererEvent } from './render'; // Accept all options of the runner as props except for the // dom element which is provided by the component itself @@ -36,6 +37,7 @@ export interface ReactExpressionRendererProps extends IExpressionLoaderParams { expression: string | ExpressionAstExpression; renderError?: (error?: string | null) => React.ReactElement | React.ReactElement[]; padding?: 'xs' | 's' | 'm' | 'l' | 'xl'; + onEvent?: (event: ExpressionRendererEvent) => void; } export type ReactExpressionRendererType = React.ComponentType; @@ -60,6 +62,7 @@ export const ReactExpressionRenderer = ({ padding, renderError, expression, + onEvent, ...expressionLoaderOptions }: ReactExpressionRendererProps) => { const mountpoint: React.MutableRefObject = useRef(null); @@ -99,6 +102,13 @@ export const ReactExpressionRenderer = ({ } : expressionLoaderOptions.onRenderError, }); + if (onEvent) { + subs.push( + expressionLoaderRef.current.events$.subscribe(event => { + onEvent(event); + }) + ); + } subs.push( expressionLoaderRef.current.loading$.subscribe(() => { hasHandledErrorRef.current = false; @@ -123,7 +133,7 @@ export const ReactExpressionRenderer = ({ errorRenderHandlerRef.current = null; }; - }, [hasCustomRenderErrorHandler]); + }, [hasCustomRenderErrorHandler, onEvent]); // Re-fetch data automatically when the inputs change useShallowCompareEffect( diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index 4aaf0da60fc60..c8a4022a01131 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -32,7 +32,7 @@ export interface ExpressionRenderHandlerParams { onRenderError: RenderErrorHandlerFnType; } -interface Event { +export interface ExpressionRendererEvent { name: string; data: any; } @@ -45,7 +45,7 @@ interface UpdateValue { export class ExpressionRenderHandler { render$: Observable; update$: Observable; - events$: Observable; + events$: Observable; private element: HTMLElement; private destroyFn?: any; @@ -63,7 +63,7 @@ export class ExpressionRenderHandler { this.element = element; this.eventsSubject = new Rx.Subject(); - this.events$ = this.eventsSubject.asObservable() as Observable; + this.events$ = this.eventsSubject.asObservable() as Observable; this.onRenderError = onRenderError || defaultRenderErrorHandler; diff --git a/src/plugins/expressions/server/index.ts b/src/plugins/expressions/server/index.ts index e41135b693922..61d3838466bef 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 6511a21b15c44..e85100996d4a1 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 3898e2b5338b1..b477e65017ed3 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 adf94f5567096..434f0b0b83f98 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 e272f3efb5abe..1521c9820c400 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 6d94e7507ff42..dadbf913d5ed5 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 8908838bd558a..2fa22fa2c2d70 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 d00951b524530..c52620e150b5f 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 a694802663171..4514b61570b07 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 10f0eb3e4f34f..9d33d9bf786d0 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 a8146e024a37e..96c02f24e347a 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 8b12f38274ee9..8bf4333cb018f 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 e2f976c0f377b..1c62366251661 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 ad0ce4a58c738..19db58e3456e7 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 e1423e96b1d47..1fbaa44817226 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 4d9d0c9ee68d7..8c603697c4713 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 53803a9358a14..3d88cce36d752 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 d405e77918546..adc7a494200c1 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 9922cb0e6341e..5739c03954def 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 2055196f833b2..a6a1a9c6d3a06 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 82ce098018e0b..fee8d036db757 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 b29ab20cb6653..e72e0ef300e04 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 74aa1ef772c85..746e65b71008c 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 466f713d35e06..bcea7f1221e1f 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 276ceedbbcc68..69e498ac59459 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 1a10dc3849471..f02695e207dd3 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 a1c994d670a3d..4b418587f78b2 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 90e4ac6026dad..1d6b19c4cec2e 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 e003f4dfd47e4..178a371f9212e 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 d18cc31512e71..1148caeb441f8 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 3f6cb36d8d49e..17c37755b6bc3 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 27b5507ff6672..bce08e85c6977 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 756d4a171d858..37d0cc106bfe5 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 82af4d6c42dd8..8671f7218ffc8 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 b0ff61c7116ce..eb539e15c1bcd 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 bce928519f66d..34a1b9e7f619d 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 a6c98fb16671f..975b549c9520b 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 def9f71c9d2df..0c28061985819 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 b16267aeb0de6..f9bb9d249e755 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 b62a7ff731420..a646068e4ff34 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 27c288ce9c381..e017fae0499a3 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 27c7780653168..bcc4d9bb0b67b 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 b352691f06afe..2c2246b15d7fa 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 7dd949704d3cf..616bc7450249e 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 c1d4a354e9496..1dc297e78c791 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 a3812fda147f5..c02cb05889ebb 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 ab8184c1b3249..9bad70699a6ed 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 456804c51f838..ef1a84ecdbf10 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 56f1d56ea0123..1876edd6c0bf7 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 8fe81eca4c601..a97ee3ab9758a 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 207bc0cb479be..fa854a1c23505 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 a1dfbc64ec244..bbe4ea78ee87c 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 908b6440f88c6..81bf99f1ec3c1 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 251825147ded1..4bd54c96481b6 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 581b4a14a2f38..f74f65cbc6b7d 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 36903f2d7c90f..90823359359a1 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 26a29bc470e8a..29ffa4cd486b5 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 0000000000000..14d6e52dc0465 --- /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 c634322b23d0b..3d8a4414de70c 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 0000000000000..a4a9f09c1c0e0 --- /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 0000000000000..54d47ac7e980f --- /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/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index fb3d6efa63826..3b7f48f366400 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -238,6 +238,8 @@ exports[`SavedObjectsTable import should show the flyout 1`] = ` indexPatterns={ Object { "clearCache": [MockFunction], + "createField": [MockFunction], + "createFieldList": [MockFunction], "ensureDefaultIndexPattern": [MockFunction], "get": [MockFunction], "make": [Function], @@ -260,19 +262,10 @@ exports[`SavedObjectsTable import should show the flyout 1`] = ` search={ Object { "__LEGACY": Object { - "AggConfig": [MockFunction], - "AggType": [MockFunction], - "FieldParamType": [MockFunction], - "MetricAggType": [MockFunction], - "aggTypeFieldFilters": AggTypeFieldFilters { - "filters": Set {}, - }, "esClient": Object { "msearch": [MockFunction], "search": [MockFunction], }, - "parentPipelineAggHelper": [MockFunction], - "siblingPipelineAggHelper": [MockFunction], }, "aggs": Object { "calculateAutoTimeExpression": [Function], diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index feaa1f6a60e2f..f5dbbc9f923ac 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 79fda78401abd..0000000000000 --- 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 0000000000000..b14346180c274 --- /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 0000000000000..4cbc4dd2a053c --- /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 cc66f221e4082..dea21678eccea 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 64bfd368e3dfa..88e42ff2ec113 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 d26740ffdf033..0c19d20ed1bda 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 4d794618e85ab..c723388c021e9 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 49b6bd5e17699..a9b413fb36542 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 c1be6b2626525..3522ac4941ba0 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 928e57937a9b5..71148656cbb16 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 bdf71a25e6dbc..45a1bdffa52ad 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 f7718e63773f5..9a08aeabb00f3 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 5b427f918c173..ade21ee4b7d91 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 f5a6a96fb41a4..55ccac42ff255 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 c5e68e5d5ca5a..21dd17ed82e3f 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 7d63b1b6d5669..dfa71cec89595 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 c638db0ce9dab..c7c998907381a 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 1fc92d7c0cb1b..e499c404ae745 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 ad32bdc1b564e..5fe060f55dc77 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 e6247a8bafff7..85c87306cc4f9 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 0000000000000..a6943e54f016c --- /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 0000000000000..f43b776e74658 --- /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_default_editor/public/agg_filters/agg_type_field_filters.ts b/src/plugins/vis_default_editor/public/agg_filters/agg_type_field_filters.ts new file mode 100644 index 0000000000000..15df2f0acccd1 --- /dev/null +++ b/src/plugins/vis_default_editor/public/agg_filters/agg_type_field_filters.ts @@ -0,0 +1,49 @@ +/* + * 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 { IAggConfig, IndexPatternField } from '../../../data/public'; + +type AggTypeFieldFilter = (field: IndexPatternField, aggConfig: IAggConfig) => boolean; + +const filters: AggTypeFieldFilter[] = [ + /** + * Check index pattern aggregation restrictions + * and limit available fields for a given aggType based on that. + */ + (field, aggConfig) => { + const indexPattern = aggConfig.getIndexPattern(); + const aggRestrictions = indexPattern.getAggregationRestrictions(); + + if (!aggRestrictions) { + return true; + } + + const aggName = aggConfig.type && aggConfig.type.name; + const aggFields = aggRestrictions[aggName]; + return !!aggFields && !!aggFields[field.name]; + }, +]; + +export function filterAggTypeFields(fields: IndexPatternField[], aggConfig: IAggConfig) { + const allowedAggTypeFields = fields.filter(field => { + const isAggTypeFieldAllowed = filters.every(filter => filter(field, aggConfig)); + return isAggTypeFieldAllowed; + }); + return allowedAggTypeFields; +} diff --git a/src/plugins/vis_default_editor/public/agg_filters/agg_type_filters.ts b/src/plugins/vis_default_editor/public/agg_filters/agg_type_filters.ts new file mode 100644 index 0000000000000..2cf1acba4d228 --- /dev/null +++ b/src/plugins/vis_default_editor/public/agg_filters/agg_type_filters.ts @@ -0,0 +1,75 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IAggType, IAggConfig, IndexPattern, search } from '../../../data/public'; + +const { propFilter } = search.aggs; +const filterByName = propFilter('name'); + +type AggTypeFilter = ( + aggType: IAggType, + indexPattern: IndexPattern, + aggConfig: IAggConfig, + aggFilter: string[] +) => boolean; + +const filters: AggTypeFilter[] = [ + /** + * This filter checks the defined aggFilter in the schemas of that visualization + * and limits available aggregations based on that. + */ + (aggType, indexPattern, aggConfig, aggFilter) => { + const doesSchemaAllowAggType = filterByName([aggType], aggFilter).length !== 0; + return doesSchemaAllowAggType; + }, + /** + * Check index pattern aggregation restrictions and limit available aggTypes. + */ + (aggType, indexPattern, aggConfig, aggFilter) => { + const aggRestrictions = indexPattern.getAggregationRestrictions(); + + if (!aggRestrictions) { + return true; + } + + const aggName = aggType.name; + // Only return agg types which are specified in the agg restrictions, + // except for `count` which should always be returned. + return ( + aggName === 'count' || + (!!aggRestrictions && Object.keys(aggRestrictions).includes(aggName)) || + false + ); + }, +]; + +export function filterAggTypes( + aggTypes: IAggType[], + indexPattern: IndexPattern, + aggConfig: IAggConfig, + aggFilter: string[] +) { + const allowedAggTypes = aggTypes.filter(aggType => { + const isAggTypeAllowed = filters.every(filter => + filter(aggType, indexPattern, aggConfig, aggFilter) + ); + return isAggTypeAllowed; + }); + return allowedAggTypes; +} diff --git a/src/plugins/vis_default_editor/public/agg_filters/index.ts b/src/plugins/vis_default_editor/public/agg_filters/index.ts new file mode 100644 index 0000000000000..2b08449fb3161 --- /dev/null +++ b/src/plugins/vis_default_editor/public/agg_filters/index.ts @@ -0,0 +1,21 @@ +/* + * 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 './agg_type_filters'; +export * from './agg_type_field_filters'; diff --git a/src/plugins/vis_default_editor/public/components/agg_common_props.ts b/src/plugins/vis_default_editor/public/components/agg_common_props.ts index 0c130a96230b4..40d7b79bfbefc 100644 --- a/src/plugins/vis_default_editor/public/components/agg_common_props.ts +++ b/src/plugins/vis_default_editor/public/components/agg_common_props.ts @@ -18,7 +18,7 @@ */ import { VisParams } from 'src/plugins/visualizations/public'; -import { IAggType, IAggConfig, IAggGroupNames } from 'src/plugins/data/public'; +import { IAggType, IAggConfig, AggGroupName } from 'src/plugins/data/public'; import { Schema } from '../schemas'; import { EditorVisState } from './sidebar/state/reducers'; @@ -30,7 +30,7 @@ export type ReorderAggs = (sourceAgg: IAggConfig, destinationAgg: IAggConfig) => export interface DefaultEditorCommonProps { formIsTouched: boolean; - groupName: IAggGroupNames; + groupName: AggGroupName; metricAggs: IAggConfig[]; state: EditorVisState; setAggParamValue: ( diff --git a/src/plugins/vis_default_editor/public/components/agg_group.tsx b/src/plugins/vis_default_editor/public/components/agg_group.tsx index 72515d0845926..fae9de6959ef1 100644 --- a/src/plugins/vis_default_editor/public/components/agg_group.tsx +++ b/src/plugins/vis_default_editor/public/components/agg_group.tsx @@ -30,7 +30,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AggGroupNames, search, IAggConfig, TimeRange } from '../../../data/public'; +import { AggGroupNames, AggGroupLabels, IAggConfig, TimeRange } from '../../../data/public'; import { DefaultEditorAgg } from './agg'; import { DefaultEditorAggAdd } from './agg_add'; import { AddSchema, ReorderAggs, DefaultEditorAggCommonProps } from './agg_common_props'; @@ -70,7 +70,7 @@ function DefaultEditorAggGroup({ setValidity, timeRange, }: DefaultEditorAggGroupProps) { - const groupNameLabel = (search.aggs.aggGroupNamesMap() as any)[groupName]; + const groupNameLabel = AggGroupLabels[groupName]; // e.g. buckets can have no aggs const schemaNames = schemas.map(s => s.name); const group: IAggConfig[] = useMemo( diff --git a/src/plugins/vis_default_editor/public/components/agg_param_props.ts b/src/plugins/vis_default_editor/public/components/agg_param_props.ts index aec332e8674d7..076bddc9551ea 100644 --- a/src/plugins/vis_default_editor/public/components/agg_param_props.ts +++ b/src/plugins/vis_default_editor/public/components/agg_param_props.ts @@ -17,7 +17,12 @@ * under the License. */ -import { IAggConfig, AggParam, IndexPatternField } from 'src/plugins/data/public'; +import { + IAggConfig, + AggParam, + IndexPatternField, + OptionedValueProp, +} from 'src/plugins/data/public'; import { ComboBoxGroupedOptions } from '../utils'; import { EditorConfig } from './utils'; import { Schema } from '../schemas'; @@ -46,3 +51,9 @@ export interface AggParamEditorProps extends AggParamCommonProp setValidity(isValid: boolean): void; setTouched(): void; } + +export interface OptionedParamEditorProps { + aggParam: { + options: T[]; + }; +} diff --git a/src/plugins/vis_default_editor/public/components/agg_params.tsx b/src/plugins/vis_default_editor/public/components/agg_params.tsx index 3674e39b558d2..d36c2d0e7625b 100644 --- a/src/plugins/vis_default_editor/public/components/agg_params.tsx +++ b/src/plugins/vis_default_editor/public/components/agg_params.tsx @@ -112,20 +112,8 @@ function DefaultEditorAggParams({ fieldName, ]); const params = useMemo( - () => - getAggParamsToRender( - { agg, editorConfig, metricAggs, state, schemas, hideCustomLabel }, - services.data.search.__LEGACY.aggTypeFieldFilters - ), - [ - agg, - editorConfig, - metricAggs, - state, - schemas, - hideCustomLabel, - services.data.search.__LEGACY.aggTypeFieldFilters, - ] + () => getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas, hideCustomLabel }), + [agg, editorConfig, metricAggs, state, schemas, hideCustomLabel] ); const allParams = [...params.basic, ...params.advanced]; const [paramsState, onChangeParamsState] = useReducer( diff --git a/src/plugins/vis_default_editor/public/components/agg_params_helper.test.ts b/src/plugins/vis_default_editor/public/components/agg_params_helper.test.ts index bed2561341737..834ad8b70ad0d 100644 --- a/src/plugins/vis_default_editor/public/components/agg_params_helper.test.ts +++ b/src/plugins/vis_default_editor/public/components/agg_params_helper.test.ts @@ -23,7 +23,6 @@ import { IAggConfig, IAggType, IndexPattern, - IndexPatternField, } from 'src/plugins/data/public'; import { getAggParamsToRender, @@ -39,12 +38,6 @@ jest.mock('../utils', () => ({ groupAndSortBy: jest.fn(() => ['indexedFields']), })); -const mockFilter: any = { - filter(fields: IndexPatternField[]): IndexPatternField[] { - return fields; - }, -}; - describe('DefaultEditorAggParams helpers', () => { describe('getAggParamsToRender', () => { let agg: IAggConfig; @@ -72,20 +65,14 @@ describe('DefaultEditorAggParams helpers', () => { }, schema: 'metric', } as IAggConfig; - const params = getAggParamsToRender( - { agg, editorConfig, metricAggs, state, schemas }, - mockFilter - ); + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual(emptyParams); }); it('should not create any param if there is no agg type', () => { agg = { schema: 'metric' } as IAggConfig; - const params = getAggParamsToRender( - { agg, editorConfig, metricAggs, state, schemas }, - mockFilter - ); + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual(emptyParams); }); @@ -101,10 +88,7 @@ describe('DefaultEditorAggParams helpers', () => { hidden: true, }, }; - const params = getAggParamsToRender( - { agg, editorConfig, metricAggs, state, schemas }, - mockFilter - ); + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual(emptyParams); }); @@ -116,10 +100,7 @@ describe('DefaultEditorAggParams helpers', () => { }, schema: 'metric2', } as any) as IAggConfig; - const params = getAggParamsToRender( - { agg, editorConfig, metricAggs, state, schemas }, - mockFilter - ); + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual(emptyParams); }); @@ -152,16 +133,14 @@ describe('DefaultEditorAggParams helpers', () => { { name: '@timestamp', type: 'date' }, { name: 'geo_desc', type: 'string' }, ], + getAggregationRestrictions: jest.fn(), })), params: { orderBy: 'orderBy', field: 'field', }, } as any) as IAggConfig; - const params = getAggParamsToRender( - { agg, editorConfig, metricAggs, state, schemas }, - mockFilter - ); + const params = getAggParamsToRender({ agg, editorConfig, metricAggs, state, schemas }); expect(params).toEqual({ basic: [ @@ -190,7 +169,6 @@ describe('DefaultEditorAggParams helpers', () => { ], advanced: [], }); - expect(agg.getIndexPattern).toBeCalledTimes(1); }); }); diff --git a/src/plugins/vis_default_editor/public/components/agg_params_helper.ts b/src/plugins/vis_default_editor/public/components/agg_params_helper.ts index a32bd76bafa5a..9977f1e5e71fc 100644 --- a/src/plugins/vis_default_editor/public/components/agg_params_helper.ts +++ b/src/plugins/vis_default_editor/public/components/agg_params_helper.ts @@ -20,7 +20,6 @@ import { get, isEmpty } from 'lodash'; import { - AggTypeFieldFilters, IAggConfig, AggParam, IFieldParamType, @@ -28,13 +27,13 @@ import { IndexPattern, IndexPatternField, } from 'src/plugins/data/public'; +import { filterAggTypes, filterAggTypeFields } from '../agg_filters'; import { groupAndSortBy, ComboBoxGroupedOptions } from '../utils'; import { AggTypeState, AggParamsState } from './agg_params_state'; import { AggParamEditorProps } from './agg_param_props'; import { aggParamsMap } from './agg_params_map'; import { EditorConfig } from './utils'; import { Schema, getSchemaByName } from '../schemas'; -import { search } from '../../../data/public'; import { EditorVisState } from './sidebar/state/reducers'; interface ParamInstanceBase { @@ -53,10 +52,14 @@ export interface ParamInstance extends ParamInstanceBase { value: unknown; } -function getAggParamsToRender( - { agg, editorConfig, metricAggs, state, schemas, hideCustomLabel }: ParamInstanceBase, - aggTypeFieldFilters: AggTypeFieldFilters -) { +function getAggParamsToRender({ + agg, + editorConfig, + metricAggs, + state, + schemas, + hideCustomLabel, +}: ParamInstanceBase) { const params = { basic: [] as ParamInstance[], advanced: [] as ParamInstance[], @@ -89,7 +92,7 @@ function getAggParamsToRender( availableFields = availableFields.filter(field => field.type === 'number'); } } - fields = aggTypeFieldFilters.filter(availableFields, agg); + fields = filterAggTypeFields(availableFields, agg); indexedFields = groupAndSortBy(fields, 'type', 'name'); if (fields && !indexedFields.length && index > 0) { @@ -138,12 +141,7 @@ function getAggTypeOptions( groupName: string, allowedAggs: string[] ): ComboBoxGroupedOptions { - const aggTypeOptions = search.aggs.aggTypeFilters.filter( - aggTypes[groupName], - indexPattern, - agg, - allowedAggs - ); + const aggTypeOptions = filterAggTypes(aggTypes[groupName], indexPattern, agg, allowedAggs); return groupAndSortBy(aggTypeOptions as any[], 'subtype', 'title'); } diff --git a/src/plugins/vis_default_editor/public/components/controls/order.tsx b/src/plugins/vis_default_editor/public/components/controls/order.tsx index e609bf9adf790..3c0224564300a 100644 --- a/src/plugins/vis_default_editor/public/components/controls/order.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/order.tsx @@ -21,8 +21,8 @@ import React, { useEffect } from 'react'; import { EuiFormRow, EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { OptionedValueProp, OptionedParamEditorProps } from 'src/plugins/data/public'; -import { AggParamEditorProps } from '../agg_param_props'; +import { OptionedValueProp } from 'src/plugins/data/public'; +import { AggParamEditorProps, OptionedParamEditorProps } from '../agg_param_props'; function OrderParamEditor({ aggParam, diff --git a/src/plugins/vis_default_editor/public/components/controls/top_aggregate.tsx b/src/plugins/vis_default_editor/public/components/controls/top_aggregate.tsx index ad23cec87800f..66abb88b97d29 100644 --- a/src/plugins/vis_default_editor/public/components/controls/top_aggregate.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/top_aggregate.tsx @@ -26,10 +26,9 @@ import { IAggConfig, AggParam, OptionedValueProp, - OptionedParamEditorProps, OptionedParamType, } from 'src/plugins/data/public'; -import { AggParamEditorProps } from '../agg_param_props'; +import { AggParamEditorProps, OptionedParamEditorProps } from '../agg_param_props'; export interface AggregateValueProp extends OptionedValueProp { isCompatible(aggConfig: IAggConfig): boolean; diff --git a/src/plugins/vis_default_editor/public/default_editor.tsx b/src/plugins/vis_default_editor/public/default_editor.tsx index 1c2ddbc314f99..8088921ba7fda 100644 --- a/src/plugins/vis_default_editor/public/default_editor.tsx +++ b/src/plugins/vis_default_editor/public/default_editor.tsx @@ -22,7 +22,6 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { EditorRenderProps } from 'src/plugins/visualize/public'; import { PanelsContainer, Panel } from '../../kibana_react/public'; -import './vis_type_agg_filter'; import { DefaultEditorSideBar } from './components/sidebar'; import { DefaultEditorControllerState } from './default_editor_controller'; import { getInitialWidth } from './editor_size'; diff --git a/src/plugins/vis_default_editor/public/schemas.ts b/src/plugins/vis_default_editor/public/schemas.ts index 05ba5fa9c9419..26d1cbe91b996 100644 --- a/src/plugins/vis_default_editor/public/schemas.ts +++ b/src/plugins/vis_default_editor/public/schemas.ts @@ -21,7 +21,7 @@ import _, { defaults } from 'lodash'; import { Optional } from '@kbn/utility-types'; -import { AggGroupNames, AggParam, IAggGroupNames } from '../../data/public'; +import { AggGroupNames, AggParam, AggGroupName } from '../../data/public'; export interface ISchemas { [AggGroupNames.Buckets]: Schema[]; @@ -32,7 +32,7 @@ export interface ISchemas { export interface Schema { aggFilter: string[]; editor: boolean | string; - group: IAggGroupNames; + group: AggGroupName; max: number; min: number; name: string; diff --git a/src/plugins/vis_default_editor/public/vis_type_agg_filter.ts b/src/plugins/vis_default_editor/public/vis_type_agg_filter.ts deleted file mode 100644 index bf5661f42a9f5..0000000000000 --- a/src/plugins/vis_default_editor/public/vis_type_agg_filter.ts +++ /dev/null @@ -1,33 +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 { IAggType, IAggConfig, IndexPattern, search } from '../../data/public'; - -const { aggTypeFilters, propFilter } = search.aggs; -const filterByName = propFilter('name'); - -/** - * This filter checks the defined aggFilter in the schemas of that visualization - * and limits available aggregations based on that. - */ -aggTypeFilters.addFilter( - (aggType: IAggType, indexPatterns: IndexPattern, aggConfig: IAggConfig, aggFilter: string[]) => { - const doesSchemaAllowAggType = filterByName([aggType], aggFilter).length !== 0; - return doesSchemaAllowAggType; - } -); diff --git a/src/plugins/vis_type_markdown/public/markdown_vis.ts b/src/plugins/vis_type_markdown/public/markdown_vis.ts index b84d9638eb973..3309330d7527c 100644 --- a/src/plugins/vis_type_markdown/public/markdown_vis.ts +++ b/src/plugins/vis_type_markdown/public/markdown_vis.ts @@ -21,7 +21,7 @@ import { i18n } from '@kbn/i18n'; import { MarkdownVisWrapper } from './markdown_vis_controller'; import { MarkdownOptions } from './markdown_options'; -import { SettingsOptions } from './settings_options'; +import { SettingsOptions } from './settings_options_lazy'; import { DefaultEditorSize } from '../../vis_default_editor/public'; export const markdownVisDefinition = { diff --git a/src/plugins/vis_type_markdown/public/settings_options.tsx b/src/plugins/vis_type_markdown/public/settings_options.tsx index 6f6a80564ce07..bf4570db5d4a0 100644 --- a/src/plugins/vis_type_markdown/public/settings_options.tsx +++ b/src/plugins/vis_type_markdown/public/settings_options.tsx @@ -52,4 +52,6 @@ function SettingsOptions({ stateParams, setValue }: VisOptionsProps import('./settings_options')); + +export const SettingsOptions = (props: any) => ( + }> + + +); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js index 9fdb8ccc919b7..62d8cf3297132 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js @@ -74,6 +74,8 @@ export class VisEditor extends Component { handleUiState = (field, value) => { this.props.vis.uiState.set(field, value); + // reload visualization because data might need to be re-fetched + this.props.vis.uiState.emit('reload'); }; updateVisState = debounce(() => { diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 5cf1619150e5c..dc0c4310de576 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -87,7 +87,7 @@ export const TimeSeries = ({ const tooltipFormatter = decorateFormatter(xAxisFormatter); const uiSettings = getUISettings(); const timeZone = getTimezone(uiSettings); - const hasBarChart = series.some(({ bars }) => bars.show); + const hasBarChart = series.some(({ bars }) => bars?.show); // compute the theme based on the bg color const theme = getTheme(darkMode, backgroundColor); @@ -100,13 +100,21 @@ export const TimeSeries = ({ const { colors } = getChartsSetup(); colors.mappedColors.mapKeys(series.filter(({ color }) => !color).map(({ label }) => label)); + const onBrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + onBrush(min, max); + }; + return ( 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 df63a14ea5ee4..cc6466145dcdf 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 32a75b1268d06..0ca562c49b4c7 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 857f2ab1d0485..d390821f9ad98 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 0a701d1de577f..f76f3a531a37d 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 1ff548cc19e02..45db28fa98f5e 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 bbb7d60c8ef06..d677b2564c940 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 54424bed0688b..c567e8ded0e61 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 80b4ef70a3f08..0822878aa9178 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 d023c28cdb25e..a3d2725ef58b5 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 3ad00272c66cb..db5a3f50f2e62 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 6afa434a55085..6b51415627fe9 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 a05c414f1a311..8bce521e742d8 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 44418efe42dbb..d38282ed3e9aa 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 2b5014a2535dc..c38351e37dc31 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 972a8c71ed515..6597973c28cf0 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 758da28e93232..b7ffbaa65619c 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 35036abed320f..fd03921346fb8 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 5b7ae735cd50f..a34d53a6bc975 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/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.js index 6ba062527ed52..4479cec721e4c 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.js @@ -17,37 +17,37 @@ * under the License. */ -import _ from 'lodash'; -import { getSplits } from '../../helpers/get_splits'; -import { getLastMetric } from '../../helpers/get_last_metric'; -import { mapBucket } from '../../helpers/map_bucket'; +import { getAggValue, getLastMetric, getSplits } from '../../helpers'; +import { METRIC_TYPES } from '../../../../../common/metric_types'; export function stdDeviationBands(resp, panel, series, meta) { return next => results => { const metric = getLastMetric(series); - if (metric.type === 'std_deviation' && metric.mode === 'band') { - getSplits(resp, panel, series, meta).forEach(split => { - const upper = split.timeseries.buckets.map( - mapBucket(_.assign({}, metric, { mode: 'upper' })) - ); - const lower = split.timeseries.buckets.map( - mapBucket(_.assign({}, metric, { mode: 'lower' })) - ); - results.push({ - id: `${split.id}:upper`, - label: split.label, - color: split.color, - lines: { show: true, fill: 0.5, lineWidth: 0 }, - points: { show: false }, - fillBetween: `${split.id}:lower`, - data: upper, - }); + if (metric.type === METRIC_TYPES.STD_DEVIATION && metric.mode === 'band') { + getSplits(resp, panel, series, meta).forEach(({ id, color, label, timeseries }) => { + const data = timeseries.buckets.map(bucket => [ + bucket.key, + getAggValue(bucket, { ...metric, mode: 'upper' }), + getAggValue(bucket, { ...metric, mode: 'lower' }), + ]); + results.push({ - id: `${split.id}:lower`, - color: split.color, - lines: { show: true, fill: false, lineWidth: 0 }, + id, + label, + color, + data, + lines: { + show: series.chart_type === 'line', + fill: 0.5, + lineWidth: 0, + mode: 'band', + }, + bars: { + show: series.chart_type === 'bar', + fill: 0.5, + mode: 'band', + }, points: { show: false }, - data: lower, }); }); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.test.js index 77949ff94dc4c..a229646ba8f3f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_bands.test.js @@ -86,29 +86,18 @@ describe('stdDeviationBands(resp, panel, series)', () => { test('creates a series', () => { const next = results => results; const results = stdDeviationBands(resp, panel, series)(next)([]); - expect(results).toHaveLength(2); + expect(results).toHaveLength(1); expect(results[0]).toEqual({ - id: 'test:upper', + id: 'test', label: 'Std. Deviation of cpu', color: 'rgb(255, 0, 0)', - lines: { show: true, fill: 0.5, lineWidth: 0 }, - points: { show: false }, - fillBetween: 'test:lower', - data: [ - [1, 3.2], - [2, 3.5], - ], - }); - - expect(results[1]).toEqual({ - id: 'test:lower', - color: 'rgb(255, 0, 0)', - lines: { show: true, fill: false, lineWidth: 0 }, + lines: { show: true, fill: 0.5, lineWidth: 0, mode: 'band' }, + bars: { show: false, fill: 0.5, mode: 'band' }, points: { show: false }, data: [ - [1, 0.2], - [2, 0.5], + [1, 3.2, 0.2], + [2, 3.5, 0.5], ], }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.js index 96ead42c55253..1c6ee94050a62 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.js @@ -17,40 +17,36 @@ * under the License. */ -import _ from 'lodash'; -import { getSplits } from '../../helpers/get_splits'; -import { getLastMetric } from '../../helpers/get_last_metric'; -import { getSiblingAggValue } from '../../helpers/get_sibling_agg_value'; +import { getSplits, getLastMetric, getSiblingAggValue } from '../../helpers'; export function stdDeviationSibling(resp, panel, series, meta) { return next => results => { const metric = getLastMetric(series); if (metric.mode === 'band' && metric.type === 'std_deviation_bucket') { getSplits(resp, panel, series, meta).forEach(split => { - const mapBucketByMode = mode => { - return bucket => { - return [bucket.key, getSiblingAggValue(split, _.assign({}, metric, { mode }))]; - }; - }; + const data = split.timeseries.buckets.map(bucket => [ + bucket.key, + getSiblingAggValue(split, { ...metric, mode: 'upper' }), + getSiblingAggValue(split, { ...metric, mode: 'lower' }), + ]); - const upperData = split.timeseries.buckets.map(mapBucketByMode('upper')); - const lowerData = split.timeseries.buckets.map(mapBucketByMode('lower')); - - results.push({ - id: `${split.id}:lower`, - lines: { show: true, fill: false, lineWidth: 0 }, - points: { show: false }, - color: split.color, - data: lowerData, - }); results.push({ - id: `${split.id}:upper`, + id: split.id, label: split.label, color: split.color, - lines: { show: true, fill: 0.5, lineWidth: 0 }, + lines: { + show: series.chart_type === 'line', + fill: 0.5, + lineWidth: 0, + mode: 'band', + }, + bars: { + show: series.chart_type === 'bar', + fill: 0.5, + mode: 'band', + }, points: { show: false }, - fillBetween: `${split.id}:lower`, - data: upperData, + data, }); }); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.test.js index adc5a3a4a991b..b93d929d5157a 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/std_deviation_sibling.test.js @@ -86,29 +86,18 @@ describe('stdDeviationSibling(resp, panel, series)', () => { test('creates a series', () => { const next = results => results; const results = stdDeviationSibling(resp, panel, series)(next)([]); - expect(results).toHaveLength(2); + expect(results).toHaveLength(1); expect(results[0]).toEqual({ - id: 'test:lower', + id: 'test', color: 'rgb(255, 0, 0)', - lines: { show: true, fill: false, lineWidth: 0 }, - points: { show: false }, - data: [ - [1, 0.01], - [2, 0.01], - ], - }); - - expect(results[1]).toEqual({ - id: 'test:upper', label: 'Overall Std. Deviation of Average of cpu', - color: 'rgb(255, 0, 0)', - fillBetween: 'test:lower', - lines: { show: true, fill: 0.5, lineWidth: 0 }, + lines: { show: true, fill: 0.5, lineWidth: 0, mode: 'band' }, + bars: { show: false, fill: 0.5, mode: 'band' }, points: { show: false }, data: [ - [1, 0.7], - [2, 0.7], + [1, 0.7, 0.01], + [2, 0.7, 0.01], ], }); }); diff --git a/src/plugins/vis_type_vega/public/vega_request_handler.ts b/src/plugins/vis_type_vega/public/vega_request_handler.ts index 196e8fdcbafda..efc02e368efa8 100644 --- a/src/plugins/vis_type_vega/public/vega_request_handler.ts +++ b/src/plugins/vis_type_vega/public/vega_request_handler.ts @@ -19,8 +19,6 @@ import { Filter, esQuery, TimeRange, Query } from '../../data/public'; -// @ts-ignore -import { VegaParser } from './data_model/vega_parser'; // @ts-ignore import { SearchCache } from './data_model/search_cache'; // @ts-ignore @@ -46,7 +44,12 @@ export function createVegaRequestHandler({ const { timefilter } = data.query.timefilter; const timeCache = new TimeCache(timefilter, 3 * 1000); - return ({ timeRange, filters, query, visParams }: VegaRequestHandlerParams) => { + return async function vegaRequestHandler({ + timeRange, + filters, + query, + visParams, + }: VegaRequestHandlerParams) { if (!searchCache) { searchCache = new SearchCache(getData().search.__LEGACY.esClient, { max: 10, @@ -58,8 +61,10 @@ export function createVegaRequestHandler({ const esQueryConfigs = esQuery.getEsQueryConfig(uiSettings); const filtersDsl = esQuery.buildEsQuery(undefined, query, filters, esQueryConfigs); + // @ts-ignore + const { VegaParser } = await import('./data_model/vega_parser'); const vp = new VegaParser(visParams.spec, searchCache, timeCache, filtersDsl, serviceSettings); - return vp.parseAsync(); + return await vp.parseAsync(); }; } diff --git a/src/plugins/vis_type_vega/public/vega_visualization.js b/src/plugins/vis_type_vega/public/vega_visualization.js index a6e911de7f0cb..1fcb89f04457d 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.js @@ -17,8 +17,6 @@ * under the License. */ import { i18n } from '@kbn/i18n'; -import { VegaView } from './vega_view/vega_view'; -import { VegaMapView } from './vega_view/vega_map_view'; import { getNotifications, getData, getSavedObjects } from './services'; export const createVegaVisualization = ({ serviceSettings }) => @@ -117,8 +115,10 @@ export const createVegaVisualization = ({ serviceSettings }) => if (vegaParser.useMap) { const services = { toastService: getNotifications().toasts }; + const { VegaMapView } = await import('./vega_view/vega_map_view'); this._vegaView = new VegaMapView(vegaViewParams, services); } else { + const { VegaView } = await import('./vega_view/vega_view'); this._vegaView = new VegaView(vegaViewParams); } await this._vegaView.init(); diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 1c545bb36cff0..8ab144bc03c32 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -60,6 +60,7 @@ export interface VisualizeInput extends EmbeddableInput { vis?: { colors?: { [key: string]: string }; }; + table?: unknown; } export interface VisualizeOutput extends EmbeddableOutput { @@ -77,7 +78,7 @@ export class VisualizeEmbeddable extends Embeddable; private subscriptions: Subscription[] = []; private expression: string = ''; private vis: Vis; @@ -108,6 +109,7 @@ export class VisualizeEmbeddable extends Embeddable { - this.vis.uiState.set(key, visCustomizations[key]); - }); + if (visCustomizations.vis) { + this.vis.uiState.set('vis', visCustomizations.vis); + getKeys(visCustomizations).forEach(key => { + this.vis.uiState.set(key, visCustomizations[key]); + }); + } + if (visCustomizations.table) { + this.vis.uiState.set('table', visCustomizations.table); + } this.vis.uiState.on('change', this.uiStateChangeHandler); } } else if (this.parent) { @@ -265,6 +272,7 @@ export class VisualizeEmbeddable extends Embeddable s.unsubscribe()); this.vis.uiState.off('change', this.uiStateChangeHandler); + this.vis.uiState.off('reload', this.reload); if (this.handler) { this.handler.destroy(); diff --git a/src/plugins/visualize/public/application/editor/editor.js b/src/plugins/visualize/public/application/editor/editor.js index ef359dc0cc115..1c4f0c5090347 100644 --- a/src/plugins/visualize/public/application/editor/editor.js +++ b/src/plugins/visualize/public/application/editor/editor.js @@ -387,6 +387,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState stateContainer ); vis.uiState = persistedState; + vis.uiState.on('reload', embeddableHandler.reload); $scope.uiState = persistedState; $scope.savedVis = savedVis; $scope.query = initialState.query; @@ -534,6 +535,7 @@ function VisualizeAppController($scope, $route, $injector, $timeout, kbnUrlState $scope.eventEmitter.off('apply', _applyVis); unsubscribePersisted(); + vis.uiState.off('reload', embeddableHandler.reload); unsubscribeStateUpdates(); stopAllSyncing(); diff --git a/src/plugins/visualize/public/application/legacy_app.js b/src/plugins/visualize/public/application/legacy_app.js index c7cc11c1f3ff5..e14057cab6ca9 100644 --- a/src/plugins/visualize/public/application/legacy_app.js +++ b/src/plugins/visualize/public/application/legacy_app.js @@ -160,10 +160,19 @@ export function initVisualizeApp(app, deps) { ); } + // This delay is needed to prevent some navigation issues in Firefox/Safari. + // see https://github.com/elastic/kibana/issues/65161 + const delay = res => { + return new Promise(resolve => { + setTimeout(() => resolve(res), 0); + }); + }; + return data.indexPatterns .ensureDefaultIndexPattern(history) .then(() => savedVisualizations.get($route.current.params)) .then(getResolvedResults(deps)) + .then(delay) .catch( redirectWhenMissing({ history, diff --git a/src/test_utils/public/stub_index_pattern.js b/src/test_utils/public/stub_index_pattern.js index aecf8f9edee2a..98ada2471e1ec 100644 --- a/src/test_utils/public/stub_index_pattern.js +++ b/src/test_utils/public/stub_index_pattern.js @@ -23,10 +23,10 @@ import sinon from 'sinon'; // than just the type. Doing this as a temporary measure; it will be left behind when migrating to NP. import { - IndexPatternFieldList, IndexPattern, indexPatterns, KBN_FIELD_TYPES, + getIndexPatternFieldListCreator, } from '../../plugins/data/public'; import { setFieldFormats } from '../../plugins/data/public/services'; @@ -42,6 +42,16 @@ import { getFieldFormatsRegistry } from './stub_field_formats'; export default function StubIndexPattern(pattern, getConfig, timeField, fields, core) { const registeredFieldFormats = getFieldFormatsRegistry(core); + const createFieldList = getIndexPatternFieldListCreator({ + fieldFormats: { + getDefaultInstance: () => ({ + convert: val => String(val), + }), + }, + toastNotifications: { + addDanger: () => {}, + }, + }); this.id = pattern; this.title = pattern; @@ -67,7 +77,7 @@ export default function StubIndexPattern(pattern, getConfig, timeField, fields, this.formatField = this.formatHit.formatField; this._reindexFields = function() { - this.fields = new IndexPatternFieldList(this, this.fields || fields); + this.fields = createFieldList(this, this.fields || fields, false); }; this.stubSetFieldFormat = function(fieldName, id, params) { diff --git a/test/functional/apps/bundles/index.js b/test/functional/apps/bundles/index.js new file mode 100644 index 0000000000000..8a25c7cd1fafc --- /dev/null +++ b/test/functional/apps/bundles/index.js @@ -0,0 +1,73 @@ +/* + * 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. + */ + +/** + * These supertest-based tests live in the functional test suite because they depend on the optimizer bundles being built + * and served + */ +export default function({ getService }) { + const supertest = getService('supertest'); + + describe('bundle compression', function() { + this.tags('ciGroup12'); + + let buildNum; + before(async () => { + 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/discover/_discover_histogram.js b/test/functional/apps/discover/_discover_histogram.js index eeef3333aab0f..20e69ef8345c6 100644 --- a/test/functional/apps/discover/_discover_histogram.js +++ b/test/functional/apps/discover/_discover_histogram.js @@ -48,7 +48,7 @@ export default function({ getService, getPageObjects }) { log.debug('create long_window_logstash index pattern'); // NOTE: long_window_logstash load does NOT create index pattern - await PageObjects.settings.createIndexPattern('long-window-logstash-'); + await PageObjects.settings.createIndexPattern('long-window-logstash-*'); await kibanaServer.uiSettings.replace(defaultSettings); await browser.refresh(); @@ -56,7 +56,7 @@ export default function({ getService, getPageObjects }) { await PageObjects.common.navigateToApp('discover'); await PageObjects.discover.selectIndexPattern('long-window-logstash-*'); // NOTE: For some reason without setting this relative time, the abs times will not fetch data. - await PageObjects.timePicker.setCommonlyUsedTime('superDatePickerCommonlyUsed_Last_1 year'); + await PageObjects.timePicker.setCommonlyUsedTime('Last_1 year'); }); after(async () => { await esArchiver.unload('long_window_logstash'); diff --git a/test/functional/apps/getting_started/_shakespeare.js b/test/functional/apps/getting_started/_shakespeare.js index 9a4bb0081b7ad..3a3d6b93e166b 100644 --- a/test/functional/apps/getting_started/_shakespeare.js +++ b/test/functional/apps/getting_started/_shakespeare.js @@ -59,9 +59,9 @@ export default function({ getService, getPageObjects }) { it('should create shakespeare index pattern', async function() { log.debug('Create shakespeare index pattern'); - await PageObjects.settings.createIndexPattern('shakes', null); + await PageObjects.settings.createIndexPattern('shakespeare', null); const patternName = await PageObjects.settings.getIndexPageHeading(); - expect(patternName).to.be('shakes*'); + expect(patternName).to.be('shakespeare'); }); // https://www.elastic.co/guide/en/kibana/current/tutorial-visualizing.html @@ -74,7 +74,7 @@ export default function({ getService, getPageObjects }) { log.debug('create shakespeare vertical bar chart'); await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVerticalBarChart(); - await PageObjects.visualize.clickNewSearch('shakes*'); + await PageObjects.visualize.clickNewSearch('shakespeare'); await PageObjects.visChart.waitForVisualization(); const expectedChartValues = [111396]; diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.js index 616e2297b2f51..2545b8f324d1b 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.js @@ -44,10 +44,7 @@ export default function({ getService, getPageObjects }) { it('should handle special charaters in template input', async () => { await PageObjects.settings.clickAddNewIndexPatternButton(); await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.settings.setIndexPatternField({ - indexPatternName: '❤️', - expectWildcard: false, - }); + await PageObjects.settings.setIndexPatternField('❤️'); await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { diff --git a/test/functional/apps/visualize/_area_chart.js b/test/functional/apps/visualize/_area_chart.js index 8f2012d7f184d..05544029f62d7 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 0fbde95afe12c..8cc0a34e352a9 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/console_page.js b/test/functional/page_objects/console_page.js deleted file mode 100644 index 33d13d3064333..0000000000000 --- a/test/functional/page_objects/console_page.js +++ /dev/null @@ -1,82 +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 Bluebird from 'bluebird'; - -export function ConsolePageProvider({ getService }) { - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - - async function getVisibleTextFromAceEditor(editor) { - const lines = await editor.findAllByClassName('ace_line_group'); - const linesText = await Bluebird.map(lines, l => l.getVisibleText()); - return linesText.join('\n'); - } - - return new (class ConsolePage { - async getRequestEditor() { - return await testSubjects.find('request-editor'); - } - - async getRequest() { - const requestEditor = await this.getRequestEditor(); - return await getVisibleTextFromAceEditor(requestEditor); - } - - async getResponse() { - const responseEditor = await testSubjects.find('response-editor'); - return await getVisibleTextFromAceEditor(responseEditor); - } - - async clickPlay() { - await testSubjects.click('sendRequestButton'); - } - - async collapseHelp() { - await testSubjects.click('help-close-button'); - } - - async openSettings() { - await testSubjects.click('consoleSettingsButton'); - } - - async setFontSizeSetting(newSize) { - await this.openSettings(); - - // while the settings form opens/loads this may fail, so retry for a while - await retry.try(async () => { - const fontSizeInput = await testSubjects.find('setting-font-size-input'); - await fontSizeInput.clearValue({ withJS: true }); - await fontSizeInput.click(); - await fontSizeInput.type(String(newSize)); - }); - - await testSubjects.click('settings-save-button'); - } - - async getFontSize(editor) { - const aceLine = await editor.findByClassName('ace_line'); - return await aceLine.getComputedStyle('font-size'); - } - - async getRequestFontSize() { - return await this.getFontSize(await this.getRequestEditor()); - } - })(); -} diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts new file mode 100644 index 0000000000000..d8eb692a25044 --- /dev/null +++ b/test/functional/page_objects/console_page.ts @@ -0,0 +1,85 @@ +/* + * 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 '../ftr_provider_context'; +import { WebElementWrapper } from '../services/lib/web_element_wrapper'; + +export function ConsolePageProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + + class ConsolePage { + public async getVisibleTextFromAceEditor(editor: WebElementWrapper) { + const lines = await editor.findAllByClassName('ace_line_group'); + const linesText = await Promise.all(lines.map(async line => await line.getVisibleText())); + return linesText.join('\n'); + } + + public async getRequestEditor() { + return await testSubjects.find('request-editor'); + } + + public async getRequest() { + const requestEditor = await this.getRequestEditor(); + return await this.getVisibleTextFromAceEditor(requestEditor); + } + + public async getResponse() { + const responseEditor = await testSubjects.find('response-editor'); + return await this.getVisibleTextFromAceEditor(responseEditor); + } + + public async clickPlay() { + await testSubjects.click('sendRequestButton'); + } + + public async collapseHelp() { + await testSubjects.click('help-close-button'); + } + + public async openSettings() { + await testSubjects.click('consoleSettingsButton'); + } + + public async setFontSizeSetting(newSize: number) { + await this.openSettings(); + + // while the settings form opens/loads this may fail, so retry for a while + await retry.try(async () => { + const fontSizeInput = await testSubjects.find('setting-font-size-input'); + await fontSizeInput.clearValue({ withJS: true }); + await fontSizeInput.click(); + await fontSizeInput.type(String(newSize)); + }); + + await testSubjects.click('settings-save-button'); + } + + public async getFontSize(editor: WebElementWrapper) { + const aceLine = await editor.findByClassName('ace_line'); + return await aceLine.getComputedStyle('font-size'); + } + + public async getRequestFontSize() { + return await this.getFontSize(await this.getRequestEditor()); + } + } + + return new ConsolePage(); +} diff --git a/test/functional/page_objects/context_page.js b/test/functional/page_objects/context_page.js deleted file mode 100644 index 6ab082bf65292..0000000000000 --- a/test/functional/page_objects/context_page.js +++ /dev/null @@ -1,111 +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 rison from 'rison-node'; - -import getUrl from '../../../src/test_utils/get_url'; - -const DEFAULT_INITIAL_STATE = { - columns: ['@message'], -}; - -export function ContextPageProvider({ getService, getPageObjects }) { - const browser = getService('browser'); - const config = getService('config'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['header', 'common']); - const log = getService('log'); - - class ContextPage { - async navigateTo(indexPattern, anchorId, overrideInitialState = {}) { - const initialState = rison.encode({ - ...DEFAULT_INITIAL_STATE, - ...overrideInitialState, - }); - const appUrl = getUrl.noAuth(config.get('servers.kibana'), { - ...config.get('apps.context'), - hash: `${config.get('apps.context.hash')}/${indexPattern}/${anchorId}?_a=${initialState}`, - }); - - log.debug(`browser.get(${appUrl})`); - - await browser.get(appUrl); - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - await this.waitUntilContextLoadingHasFinished(); - // For lack of a better way, using a sleep to ensure page is loaded before proceeding - await PageObjects.common.sleep(1000); - } - - async getPredecessorCountPicker() { - return await testSubjects.find('predecessorsCountPicker'); - } - - async getSuccessorCountPicker() { - return await testSubjects.find('successorsCountPicker'); - } - - async getPredecessorLoadMoreButton() { - return await testSubjects.find('predecessorsLoadMoreButton'); - } - - async getSuccessorLoadMoreButton() { - return await testSubjects.find('successorsLoadMoreButton'); - } - - async clickPredecessorLoadMoreButton() { - log.debug('Click Predecessor Load More Button'); - await retry.try(async () => { - const predecessorButton = await this.getPredecessorLoadMoreButton(); - await predecessorButton.click(); - }); - await this.waitUntilContextLoadingHasFinished(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } - - async clickSuccessorLoadMoreButton() { - log.debug('Click Successor Load More Button'); - await retry.try(async () => { - const sucessorButton = await this.getSuccessorLoadMoreButton(); - await sucessorButton.click(); - }); - await this.waitUntilContextLoadingHasFinished(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } - - async waitUntilContextLoadingHasFinished() { - return await retry.try(async () => { - const successorLoadMoreButton = await this.getSuccessorLoadMoreButton(); - const predecessorLoadMoreButton = await this.getPredecessorLoadMoreButton(); - if ( - !( - (await successorLoadMoreButton.isEnabled()) && - (await successorLoadMoreButton.isDisplayed()) && - (await predecessorLoadMoreButton.isEnabled()) && - (await predecessorLoadMoreButton.isDisplayed()) - ) - ) { - throw new Error('loading context rows'); - } - }); - } - } - - return new ContextPage(); -} diff --git a/test/functional/page_objects/context_page.ts b/test/functional/page_objects/context_page.ts new file mode 100644 index 0000000000000..9cbada532cde3 --- /dev/null +++ b/test/functional/page_objects/context_page.ts @@ -0,0 +1,112 @@ +/* + * 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 rison from 'rison-node'; +import { FtrProviderContext } from '../ftr_provider_context'; +// @ts-ignore not TS yet +import getUrl from '../../../src/test_utils/get_url'; + +const DEFAULT_INITIAL_STATE = { + columns: ['@message'], +}; + +export function ContextPageProvider({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const config = getService('config'); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['header', 'common']); + const log = getService('log'); + + class ContextPage { + public async navigateTo(indexPattern: string, anchorId: string, overrideInitialState = {}) { + const initialState = rison.encode({ + ...DEFAULT_INITIAL_STATE, + ...overrideInitialState, + }); + const appUrl = getUrl.noAuth(config.get('servers.kibana'), { + ...config.get('apps.context'), + hash: `${config.get('apps.context.hash')}/${indexPattern}/${anchorId}?_a=${initialState}`, + }); + + log.debug(`browser.get(${appUrl})`); + + await browser.get(appUrl); + await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + await this.waitUntilContextLoadingHasFinished(); + // For lack of a better way, using a sleep to ensure page is loaded before proceeding + await PageObjects.common.sleep(1000); + } + + public async getPredecessorCountPicker() { + return await testSubjects.find('predecessorsCountPicker'); + } + + public async getSuccessorCountPicker() { + return await testSubjects.find('successorsCountPicker'); + } + + public async getPredecessorLoadMoreButton() { + return await testSubjects.find('predecessorsLoadMoreButton'); + } + + public async getSuccessorLoadMoreButton() { + return await testSubjects.find('successorsLoadMoreButton'); + } + + public async clickPredecessorLoadMoreButton() { + log.debug('Click Predecessor Load More Button'); + await retry.try(async () => { + const predecessorButton = await this.getPredecessorLoadMoreButton(); + await predecessorButton.click(); + }); + await this.waitUntilContextLoadingHasFinished(); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + + public async clickSuccessorLoadMoreButton() { + log.debug('Click Successor Load More Button'); + await retry.try(async () => { + const sucessorButton = await this.getSuccessorLoadMoreButton(); + await sucessorButton.click(); + }); + await this.waitUntilContextLoadingHasFinished(); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + + public async waitUntilContextLoadingHasFinished() { + return await retry.try(async () => { + const successorLoadMoreButton = await this.getSuccessorLoadMoreButton(); + const predecessorLoadMoreButton = await this.getPredecessorLoadMoreButton(); + if ( + !( + (await successorLoadMoreButton.isEnabled()) && + (await successorLoadMoreButton.isDisplayed()) && + (await predecessorLoadMoreButton.isEnabled()) && + (await predecessorLoadMoreButton.isDisplayed()) + ) + ) { + throw new Error('loading context rows'); + } + }); + } + } + + return new ContextPage(); +} diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index b76ce141a4418..36a7674c47ab0 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/page_objects/error_page.js b/test/functional/page_objects/error_page.js deleted file mode 100644 index 8ae0bd554989e..0000000000000 --- a/test/functional/page_objects/error_page.js +++ /dev/null @@ -1,48 +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 expect from '@kbn/expect'; - -export function ErrorPageProvider({ getPageObjects }) { - const PageObjects = getPageObjects(['common']); - - class ErrorPage { - async expectForbidden() { - const messageText = await PageObjects.common.getBodyText(); - expect(messageText).to.eql( - JSON.stringify({ - statusCode: 403, - error: 'Forbidden', - message: 'Forbidden', - }) - ); - } - async expectNotFound() { - const messageText = await PageObjects.common.getBodyText(); - expect(messageText).to.eql( - JSON.stringify({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }) - ); - } - } - - return new ErrorPage(); -} diff --git a/test/functional/page_objects/error_page.ts b/test/functional/page_objects/error_page.ts new file mode 100644 index 0000000000000..332ce835d0b1c --- /dev/null +++ b/test/functional/page_objects/error_page.ts @@ -0,0 +1,51 @@ +/* + * 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 expect from '@kbn/expect/expect.js'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export function ErrorPageProvider({ getPageObjects }: FtrProviderContext) { + const { common } = getPageObjects(['common']); + + class ErrorPage { + public async expectForbidden() { + const messageText = await common.getBodyText(); + expect(messageText).to.eql( + JSON.stringify({ + statusCode: 403, + error: 'Forbidden', + message: 'Forbidden', + }) + ); + } + + public async expectNotFound() { + const messageText = await common.getBodyText(); + expect(messageText).to.eql( + JSON.stringify({ + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }) + ); + } + } + + return new ErrorPage(); +} diff --git a/test/functional/page_objects/header_page.js b/test/functional/page_objects/header_page.js deleted file mode 100644 index d0a237e8f42d0..0000000000000 --- a/test/functional/page_objects/header_page.js +++ /dev/null @@ -1,99 +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. - */ - -export function HeaderPageProvider({ getService, getPageObjects }) { - const config = getService('config'); - const log = getService('log'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - const appsMenu = getService('appsMenu'); - const globalNav = getService('globalNav'); - const PageObjects = getPageObjects(['common']); - - const defaultFindTimeout = config.get('timeouts.find'); - - class HeaderPage { - async clickDiscover() { - await appsMenu.clickLink('Discover'); - await PageObjects.common.waitForTopNavToBeVisible(); - await this.awaitGlobalLoadingIndicatorHidden(); - } - - async clickVisualize() { - await appsMenu.clickLink('Visualize'); - await this.awaitGlobalLoadingIndicatorHidden(); - await retry.waitFor('first breadcrumb to be "Visualize"', async () => { - const firstBreadcrumb = await globalNav.getFirstBreadcrumb(); - if (firstBreadcrumb !== 'Visualize') { - log.debug('-- first breadcrumb =', firstBreadcrumb); - return false; - } - - return true; - }); - } - - async clickDashboard() { - await appsMenu.clickLink('Dashboard'); - await retry.waitFor('dashboard app to be loaded', async () => { - const isNavVisible = await testSubjects.exists('top-nav'); - const isLandingPageVisible = await testSubjects.exists('dashboardLandingPage'); - return isNavVisible || isLandingPageVisible; - }); - await this.awaitGlobalLoadingIndicatorHidden(); - } - - async clickStackManagement() { - await appsMenu.clickLink('Management'); - await this.awaitGlobalLoadingIndicatorHidden(); - } - - async waitUntilLoadingHasFinished() { - try { - await this.isGlobalLoadingIndicatorVisible(); - } catch (exception) { - if (exception.name === 'ElementNotVisible') { - // selenium might just have been too slow to catch it - } else { - throw exception; - } - } - await this.awaitGlobalLoadingIndicatorHidden(); - } - - async isGlobalLoadingIndicatorVisible() { - log.debug('isGlobalLoadingIndicatorVisible'); - return await testSubjects.exists('globalLoadingIndicator', { timeout: 1500 }); - } - - async awaitGlobalLoadingIndicatorHidden() { - await testSubjects.existOrFail('globalLoadingIndicator-hidden', { - allowHidden: true, - timeout: defaultFindTimeout * 10, - }); - } - - async awaitKibanaChrome() { - log.debug('awaitKibanaChrome'); - await testSubjects.find('kibanaChrome', defaultFindTimeout * 10); - } - } - - return new HeaderPage(); -} diff --git a/test/functional/page_objects/header_page.ts b/test/functional/page_objects/header_page.ts new file mode 100644 index 0000000000000..5f18034733822 --- /dev/null +++ b/test/functional/page_objects/header_page.ts @@ -0,0 +1,101 @@ +/* + * 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 '../ftr_provider_context'; + +export function HeaderPageProvider({ getService, getPageObjects }: FtrProviderContext) { + const config = getService('config'); + const log = getService('log'); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const appsMenu = getService('appsMenu'); + const globalNav = getService('globalNav'); + const PageObjects = getPageObjects(['common']); + + const defaultFindTimeout = config.get('timeouts.find'); + + class HeaderPage { + public async clickDiscover() { + await appsMenu.clickLink('Discover'); + await PageObjects.common.waitForTopNavToBeVisible(); + await this.awaitGlobalLoadingIndicatorHidden(); + } + + public async clickVisualize() { + await appsMenu.clickLink('Visualize'); + await this.awaitGlobalLoadingIndicatorHidden(); + await retry.waitFor('first breadcrumb to be "Visualize"', async () => { + const firstBreadcrumb = await globalNav.getFirstBreadcrumb(); + if (firstBreadcrumb !== 'Visualize') { + log.debug('-- first breadcrumb =', firstBreadcrumb); + return false; + } + + return true; + }); + } + + public async clickDashboard() { + await appsMenu.clickLink('Dashboard'); + await retry.waitFor('dashboard app to be loaded', async () => { + const isNavVisible = await testSubjects.exists('top-nav'); + const isLandingPageVisible = await testSubjects.exists('dashboardLandingPage'); + return isNavVisible || isLandingPageVisible; + }); + await this.awaitGlobalLoadingIndicatorHidden(); + } + + public async clickStackManagement() { + await appsMenu.clickLink('Management'); + await this.awaitGlobalLoadingIndicatorHidden(); + } + + public async waitUntilLoadingHasFinished() { + try { + await this.isGlobalLoadingIndicatorVisible(); + } catch (exception) { + if (exception.name === 'ElementNotVisible') { + // selenium might just have been too slow to catch it + } else { + throw exception; + } + } + await this.awaitGlobalLoadingIndicatorHidden(); + } + + public async isGlobalLoadingIndicatorVisible() { + log.debug('isGlobalLoadingIndicatorVisible'); + return await testSubjects.exists('globalLoadingIndicator', { timeout: 1500 }); + } + + public async awaitGlobalLoadingIndicatorHidden() { + await testSubjects.existOrFail('globalLoadingIndicator-hidden', { + allowHidden: true, + timeout: defaultFindTimeout * 10, + }); + } + + public async awaitKibanaChrome() { + log.debug('awaitKibanaChrome'); + await testSubjects.find('kibanaChrome', defaultFindTimeout * 10); + } + } + + return new HeaderPage(); +} diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index db58c3c2c7d19..4077036cbe793 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -18,15 +18,11 @@ */ import { CommonPageProvider } from './common_page'; -// @ts-ignore not TS yet import { ConsolePageProvider } from './console_page'; -// @ts-ignore not TS yet import { ContextPageProvider } from './context_page'; import { DashboardPageProvider } from './dashboard_page'; import { DiscoverPageProvider } from './discover_page'; -// @ts-ignore not TS yet import { ErrorPageProvider } from './error_page'; -// @ts-ignore not TS yet import { HeaderPageProvider } from './header_page'; import { HomePageProvider } from './home_page'; // @ts-ignore not TS yet @@ -34,14 +30,10 @@ import { MonitoringPageProvider } from './monitoring_page'; import { NewsfeedPageProvider } from './newsfeed_page'; // @ts-ignore not TS yet import { PointSeriesPageProvider } from './point_series_page'; -// @ts-ignore not TS yet import { SettingsPageProvider } from './settings_page'; import { SharePageProvider } from './share_page'; -// @ts-ignore not TS yet import { ShieldPageProvider } from './shield_page'; -// @ts-ignore not TS yet -import { TimePickerPageProvider } from './time_picker'; -// @ts-ignore not TS yet +import { TimePickerProvider } from './time_picker'; import { TimelionPageProvider } from './timelion_page'; import { VisualBuilderPageProvider } from './visual_builder_page'; import { VisualizePageProvider } from './visualize_page'; @@ -67,7 +59,7 @@ export const pageObjects = { share: SharePageProvider, shield: ShieldPageProvider, timelion: TimelionPageProvider, - timePicker: TimePickerPageProvider, + timePicker: TimePickerProvider, visualBuilder: VisualBuilderPageProvider, visualize: VisualizePageProvider, visEditor: VisualizeEditorPageProvider, diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 8864eda3823ef..81d22838d1e8b 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -334,7 +334,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider } await PageObjects.header.waitUntilLoadingHasFinished(); await retry.try(async () => { - await this.setIndexPatternField({ indexPatternName }); + await this.setIndexPatternField(indexPatternName); }); await PageObjects.common.sleep(2000); await (await this.getCreateIndexPatternGoToStep2Button()).click(); @@ -375,14 +375,32 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider return indexPatternId; } - async setIndexPatternField({ indexPatternName = 'logstash-', expectWildcard = true } = {}) { + async setIndexPatternField(indexPatternName = 'logstash-*') { log.debug(`setIndexPatternField(${indexPatternName})`); const field = await this.getIndexPatternField(); await field.clearValue(); - await field.type(indexPatternName, { charByChar: true }); + if ( + indexPatternName.charAt(0) === '*' && + indexPatternName.charAt(indexPatternName.length - 1) === '*' + ) { + // this is a special case when the index pattern name starts with '*' + // like '*:makelogs-*' where the UI will not append * + await field.type(indexPatternName, { charByChar: true }); + } else if (indexPatternName.charAt(indexPatternName.length - 1) === '*') { + // the common case where the UI will append '*' automatically so we won't type it + const tempName = indexPatternName.slice(0, -1); + await field.type(tempName, { charByChar: true }); + } else { + // case where we don't want the * appended so we'll remove it if it was added + await field.type(indexPatternName, { charByChar: true }); + const tempName = await field.getAttribute('value'); + if (tempName.length > indexPatternName.length) { + await field.type(browser.keys.DELETE, { charByChar: true }); + } + } const currentName = await field.getAttribute('value'); log.debug(`setIndexPatternField set to ${currentName}`); - expect(currentName).to.eql(`${indexPatternName}${expectWildcard ? '*' : ''}`); + expect(currentName).to.eql(indexPatternName); } async getCreateIndexPatternGoToStep2Button() { diff --git a/test/functional/page_objects/shield_page.js b/test/functional/page_objects/shield_page.js deleted file mode 100644 index 4b85c65a12f2c..0000000000000 --- a/test/functional/page_objects/shield_page.js +++ /dev/null @@ -1,32 +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. - */ - -export function ShieldPageProvider({ getService }) { - const testSubjects = getService('testSubjects'); - - class ShieldPage { - async login(user, pwd) { - await testSubjects.setValue('loginUsername', user); - await testSubjects.setValue('loginPassword', pwd); - await testSubjects.click('loginSubmit'); - } - } - - return new ShieldPage(); -} diff --git a/test/functional/page_objects/shield_page.ts b/test/functional/page_objects/shield_page.ts new file mode 100644 index 0000000000000..2b9c59373a8bc --- /dev/null +++ b/test/functional/page_objects/shield_page.ts @@ -0,0 +1,34 @@ +/* + * 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 '../ftr_provider_context'; + +export function ShieldPageProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + class ShieldPage { + async login(user: string, pwd: string) { + await testSubjects.setValue('loginUsername', user); + await testSubjects.setValue('loginPassword', pwd); + await testSubjects.click('loginSubmit'); + } + } + + return new ShieldPage(); +} diff --git a/test/functional/page_objects/time_picker.js b/test/functional/page_objects/time_picker.js deleted file mode 100644 index 2394abc9c2185..0000000000000 --- a/test/functional/page_objects/time_picker.js +++ /dev/null @@ -1,291 +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 moment from 'moment'; - -export function TimePickerPageProvider({ getService, getPageObjects }) { - const log = getService('log'); - const retry = getService('retry'); - const find = getService('find'); - const browser = getService('browser'); - const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['header', 'common']); - - class TimePickerPage { - async timePickerExists() { - return await testSubjects.exists('superDatePickerToggleQuickMenuButton'); - } - - formatDateToAbsoluteTimeString(date) { - // toISOString returns dates in format 'YYYY-MM-DDTHH:mm:ss.sssZ' - // Need to replace T with space and remove timezone - const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; - return moment(date).format(DEFAULT_DATE_FORMAT); - } - - async getTimePickerPanel() { - return await find.byCssSelector('div.euiPopover__panel-isOpen'); - } - - async waitPanelIsGone(panelElement) { - await find.waitForElementStale(panelElement); - } - - /** - * @param {String} commonlyUsedOption 'superDatePickerCommonlyUsed_This_week' - */ - async setCommonlyUsedTime(commonlyUsedOption) { - await testSubjects.click('superDatePickerToggleQuickMenuButton'); - await testSubjects.click(commonlyUsedOption); - } - - async inputValue(dataTestsubj, value) { - if (browser.isFirefox) { - const input = await testSubjects.find(dataTestsubj); - await input.clearValue(); - await input.type(value); - } else if (browser.isInternetExplorer) { - const input = await testSubjects.find(dataTestsubj); - const currentValue = await input.getAttribute('value'); - await input.type(browser.keys.ARROW_RIGHT.repeat(currentValue.length)); - await input.type(browser.keys.BACK_SPACE.repeat(currentValue.length)); - await input.type(value); - await input.click(); - } else { - await testSubjects.setValue(dataTestsubj, value); - } - } - - /** - * @param {String} fromTime MMM D, YYYY @ HH:mm:ss.SSS - * @param {String} toTime MMM D, YYYY @ HH:mm:ss.SSS - */ - async setAbsoluteRange(fromTime, toTime) { - log.debug(`Setting absolute range to ${fromTime} to ${toTime}`); - await this.showStartEndTimes(); - - // set to time - await testSubjects.click('superDatePickerendDatePopoverButton'); - let panel = await this.getTimePickerPanel(); - await testSubjects.click('superDatePickerAbsoluteTab'); - await testSubjects.click('superDatePickerAbsoluteDateInput'); - await this.inputValue('superDatePickerAbsoluteDateInput', toTime); - await PageObjects.common.sleep(500); - - // set from time - await testSubjects.click('superDatePickerstartDatePopoverButton'); - await this.waitPanelIsGone(panel); - panel = await this.getTimePickerPanel(); - await testSubjects.click('superDatePickerAbsoluteTab'); - await testSubjects.click('superDatePickerAbsoluteDateInput'); - await this.inputValue('superDatePickerAbsoluteDateInput', fromTime); - - const superDatePickerApplyButtonExists = await testSubjects.exists( - 'superDatePickerApplyTimeButton' - ); - if (superDatePickerApplyButtonExists) { - // Timepicker is in top nav - // Click super date picker apply button to apply time range - await testSubjects.click('superDatePickerApplyTimeButton'); - } else { - // Timepicker is embedded in query bar - // click query bar submit button to apply time range - await testSubjects.click('querySubmitButton'); - } - - await this.waitPanelIsGone(panel); - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - } - - get defaultStartTime() { - return 'Sep 19, 2015 @ 06:31:44.000'; - } - get defaultEndTime() { - return 'Sep 23, 2015 @ 18:31:44.000'; - } - - async setDefaultAbsoluteRange() { - await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); - } - - async isOff() { - const element = await find.byClassName('euiDatePickerRange--readOnly'); - return !!element; - } - - async isQuickSelectMenuOpen() { - return await testSubjects.exists('superDatePickerQuickMenu'); - } - - async openQuickSelectTimeMenu() { - log.debug('openQuickSelectTimeMenu'); - const isMenuOpen = await this.isQuickSelectMenuOpen(); - if (!isMenuOpen) { - log.debug('opening quick select menu'); - await retry.try(async () => { - await testSubjects.click('superDatePickerToggleQuickMenuButton'); - }); - } - } - - async closeQuickSelectTimeMenu() { - log.debug('closeQuickSelectTimeMenu'); - const isMenuOpen = await this.isQuickSelectMenuOpen(); - if (isMenuOpen) { - log.debug('closing quick select menu'); - await retry.try(async () => { - await testSubjects.click('superDatePickerToggleQuickMenuButton'); - }); - } - } - - async showStartEndTimes() { - // This first await makes sure the superDatePicker has loaded before we check for the ShowDatesButton - await testSubjects.exists('superDatePickerToggleQuickMenuButton', { timeout: 20000 }); - const isShowDatesButton = await testSubjects.exists('superDatePickerShowDatesButton'); - if (isShowDatesButton) { - await testSubjects.click('superDatePickerShowDatesButton'); - } - await testSubjects.exists('superDatePickerstartDatePopoverButton'); - } - - async getRefreshConfig(keepQuickSelectOpen = false) { - await this.openQuickSelectTimeMenu(); - const interval = await testSubjects.getAttribute( - 'superDatePickerRefreshIntervalInput', - 'value' - ); - - let selectedUnit; - const select = await testSubjects.find('superDatePickerRefreshIntervalUnitsSelect'); - const options = await find.allDescendantDisplayedByCssSelector('option', select); - await Promise.all( - options.map(async optionElement => { - const isSelected = await optionElement.isSelected(); - if (isSelected) { - selectedUnit = await optionElement.getVisibleText(); - } - }) - ); - - const toggleButtonText = await testSubjects.getVisibleText( - 'superDatePickerToggleRefreshButton' - ); - if (!keepQuickSelectOpen) { - await this.closeQuickSelectTimeMenu(); - } - - return { - interval, - units: selectedUnit, - isPaused: toggleButtonText === 'Start' ? true : false, - }; - } - - async getTimeConfig() { - await this.showStartEndTimes(); - const start = await testSubjects.getVisibleText('superDatePickerstartDatePopoverButton'); - const end = await testSubjects.getVisibleText('superDatePickerendDatePopoverButton'); - return { - start, - end, - }; - } - - async getTimeDurationForSharing() { - return await retry.try(async () => { - const element = await testSubjects.find('dataSharedTimefilterDuration'); - const data = await element.getAttribute('data-shared-timefilter-duration'); - return data; - }); - } - - async getTimeConfigAsAbsoluteTimes() { - await this.showStartEndTimes(); - - // get to time - await testSubjects.click('superDatePickerendDatePopoverButton'); - const panel = await this.getTimePickerPanel(); - await testSubjects.click('superDatePickerAbsoluteTab'); - const end = await testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); - - // get from time - await testSubjects.click('superDatePickerstartDatePopoverButton'); - await this.waitPanelIsGone(panel); - await testSubjects.click('superDatePickerAbsoluteTab'); - const start = await testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); - - return { - start, - end, - }; - } - - async getTimeDurationInHours() { - const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; - const { start, end } = await this.getTimeConfigAsAbsoluteTimes(); - - const startMoment = moment(start, DEFAULT_DATE_FORMAT); - const endMoment = moment(end, DEFAULT_DATE_FORMAT); - - return moment.duration(moment(endMoment) - moment(startMoment)).asHours(); - } - - async pauseAutoRefresh() { - log.debug('pauseAutoRefresh'); - const refreshConfig = await this.getRefreshConfig(true); - if (!refreshConfig.isPaused) { - log.debug('pause auto refresh'); - await testSubjects.click('superDatePickerToggleRefreshButton'); - await this.closeQuickSelectTimeMenu(); - } - - await this.closeQuickSelectTimeMenu(); - } - - async resumeAutoRefresh() { - log.debug('resumeAutoRefresh'); - const refreshConfig = await this.getRefreshConfig(true); - if (refreshConfig.isPaused) { - log.debug('resume auto refresh'); - await testSubjects.click('superDatePickerToggleRefreshButton'); - } - - await this.closeQuickSelectTimeMenu(); - } - - async setHistoricalDataRange() { - await this.setDefaultAbsoluteRange(); - } - - async setDefaultDataRange() { - const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; - const toTime = 'Apr 13, 2018 @ 00:00:00.000'; - await this.setAbsoluteRange(fromTime, toTime); - } - - async setLogstashDataRange() { - const fromTime = 'Apr 9, 2018 @ 00:00:00.000'; - const toTime = 'Apr 13, 2018 @ 00:00:00.000'; - await this.setAbsoluteRange(fromTime, toTime); - } - } - - return new TimePickerPage(); -} diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts new file mode 100644 index 0000000000000..92f0d090ff5ee --- /dev/null +++ b/test/functional/page_objects/time_picker.ts @@ -0,0 +1,291 @@ +/* + * 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 moment from 'moment'; +import { FtrProviderContext } from '../ftr_provider_context.d'; +import { WebElementWrapper } from '../services/lib/web_element_wrapper'; + +export function TimePickerProvider({ getService, getPageObjects }: FtrProviderContext) { + const log = getService('log'); + const retry = getService('retry'); + const find = getService('find'); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + const { header, common } = getPageObjects(['header', 'common']); + + type CommonlyUsed = + | 'Today' + | 'This_week' + | 'Last_15 minutes' + | 'Last_30 minutes' + | 'Last_1 hour' + | 'Last_24 hours' + | 'Last_7 days' + | 'Last_30 days' + | 'Last_90 days' + | 'Last_1 year'; + + class TimePicker { + defaultStartTime = 'Sep 19, 2015 @ 06:31:44.000'; + defaultEndTime = 'Sep 23, 2015 @ 18:31:44.000'; + + async setDefaultAbsoluteRange() { + await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); + } + + private async getTimePickerPanel() { + return await find.byCssSelector('div.euiPopover__panel-isOpen'); + } + + private async waitPanelIsGone(panelElement: WebElementWrapper) { + await find.waitForElementStale(panelElement); + } + + public async timePickerExists() { + return await testSubjects.exists('superDatePickerToggleQuickMenuButton'); + } + + /** + * Sets commonly used time + * @param option 'Today' | 'This_week' | 'Last_15 minutes' | 'Last_24 hours' ... + */ + async setCommonlyUsedTime(option: CommonlyUsed) { + await testSubjects.click('superDatePickerToggleQuickMenuButton'); + await testSubjects.click(`superDatePickerCommonlyUsed_${option}`); + } + + private async inputValue(dataTestSubj: string, value: string) { + if (browser.isFirefox) { + const input = await testSubjects.find(dataTestSubj); + await input.clearValue(); + await input.type(value); + } else if (browser.isInternetExplorer) { + const input = await testSubjects.find(dataTestSubj); + const currentValue = await input.getAttribute('value'); + await input.type(browser.keys.ARROW_RIGHT.repeat(currentValue.length)); + await input.type(browser.keys.BACK_SPACE.repeat(currentValue.length)); + await input.type(value); + await input.click(); + } else { + await testSubjects.setValue(dataTestSubj, value); + } + } + + private async showStartEndTimes() { + // This first await makes sure the superDatePicker has loaded before we check for the ShowDatesButton + await testSubjects.exists('superDatePickerToggleQuickMenuButton', { timeout: 20000 }); + const isShowDatesButton = await testSubjects.exists('superDatePickerShowDatesButton'); + if (isShowDatesButton) { + await testSubjects.click('superDatePickerShowDatesButton'); + } + await testSubjects.exists('superDatePickerstartDatePopoverButton'); + } + + /** + * @param {String} fromTime MMM D, YYYY @ HH:mm:ss.SSS + * @param {String} toTime MMM D, YYYY @ HH:mm:ss.SSS + */ + public async setAbsoluteRange(fromTime: string, toTime: string) { + log.debug(`Setting absolute range to ${fromTime} to ${toTime}`); + await this.showStartEndTimes(); + + // set to time + await testSubjects.click('superDatePickerendDatePopoverButton'); + let panel = await this.getTimePickerPanel(); + await testSubjects.click('superDatePickerAbsoluteTab'); + await testSubjects.click('superDatePickerAbsoluteDateInput'); + await this.inputValue('superDatePickerAbsoluteDateInput', toTime); + await common.sleep(500); + + // set from time + await testSubjects.click('superDatePickerstartDatePopoverButton'); + await this.waitPanelIsGone(panel); + panel = await this.getTimePickerPanel(); + await testSubjects.click('superDatePickerAbsoluteTab'); + await testSubjects.click('superDatePickerAbsoluteDateInput'); + await this.inputValue('superDatePickerAbsoluteDateInput', fromTime); + + const superDatePickerApplyButtonExists = await testSubjects.exists( + 'superDatePickerApplyTimeButton' + ); + if (superDatePickerApplyButtonExists) { + // Timepicker is in top nav + // Click super date picker apply button to apply time range + await testSubjects.click('superDatePickerApplyTimeButton'); + } else { + // Timepicker is embedded in query bar + // click query bar submit button to apply time range + await testSubjects.click('querySubmitButton'); + } + + await this.waitPanelIsGone(panel); + await header.awaitGlobalLoadingIndicatorHidden(); + } + + public async isOff() { + return await find.existsByCssSelector('.euiDatePickerRange--readOnly'); + } + + public async isQuickSelectMenuOpen() { + return await testSubjects.exists('superDatePickerQuickMenu'); + } + + public async openQuickSelectTimeMenu() { + log.debug('openQuickSelectTimeMenu'); + const isMenuOpen = await this.isQuickSelectMenuOpen(); + if (!isMenuOpen) { + log.debug('opening quick select menu'); + await retry.try(async () => { + await testSubjects.click('superDatePickerToggleQuickMenuButton'); + }); + } + } + + public async closeQuickSelectTimeMenu() { + log.debug('closeQuickSelectTimeMenu'); + const isMenuOpen = await this.isQuickSelectMenuOpen(); + if (isMenuOpen) { + log.debug('closing quick select menu'); + await retry.try(async () => { + await testSubjects.click('superDatePickerToggleQuickMenuButton'); + }); + } + } + + public async getRefreshConfig(keepQuickSelectOpen = false) { + await this.openQuickSelectTimeMenu(); + const interval = await testSubjects.getAttribute( + 'superDatePickerRefreshIntervalInput', + 'value' + ); + + let selectedUnit; + const select = await testSubjects.find('superDatePickerRefreshIntervalUnitsSelect'); + const options = await find.allDescendantDisplayedByCssSelector('option', select); + await Promise.all( + options.map(async optionElement => { + const isSelected = await optionElement.isSelected(); + if (isSelected) { + selectedUnit = await optionElement.getVisibleText(); + } + }) + ); + + const toggleButtonText = await testSubjects.getVisibleText( + 'superDatePickerToggleRefreshButton' + ); + if (!keepQuickSelectOpen) { + await this.closeQuickSelectTimeMenu(); + } + + return { + interval, + units: selectedUnit, + isPaused: toggleButtonText === 'Start' ? true : false, + }; + } + + public async getTimeConfig() { + await this.showStartEndTimes(); + const start = await testSubjects.getVisibleText('superDatePickerstartDatePopoverButton'); + const end = await testSubjects.getVisibleText('superDatePickerendDatePopoverButton'); + return { + start, + end, + }; + } + + public async getTimeDurationForSharing() { + return await testSubjects.getAttribute( + 'dataSharedTimefilterDuration', + 'data-shared-timefilter-duration' + ); + } + + public async getTimeConfigAsAbsoluteTimes() { + await this.showStartEndTimes(); + + // get to time + await testSubjects.click('superDatePickerendDatePopoverButton'); + const panel = await this.getTimePickerPanel(); + await testSubjects.click('superDatePickerAbsoluteTab'); + const end = await testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); + + // get from time + await testSubjects.click('superDatePickerstartDatePopoverButton'); + await this.waitPanelIsGone(panel); + await testSubjects.click('superDatePickerAbsoluteTab'); + const start = await testSubjects.getAttribute('superDatePickerAbsoluteDateInput', 'value'); + + return { + start, + end, + }; + } + + public async getTimeDurationInHours() { + const DEFAULT_DATE_FORMAT = 'MMM D, YYYY @ HH:mm:ss.SSS'; + const { start, end } = await this.getTimeConfigAsAbsoluteTimes(); + const startMoment = moment(start, DEFAULT_DATE_FORMAT); + const endMoment = moment(end, DEFAULT_DATE_FORMAT); + return moment.duration(endMoment.diff(startMoment)).asHours(); + } + + public async pauseAutoRefresh() { + log.debug('pauseAutoRefresh'); + const refreshConfig = await this.getRefreshConfig(true); + if (!refreshConfig.isPaused) { + log.debug('pause auto refresh'); + await testSubjects.click('superDatePickerToggleRefreshButton'); + await this.closeQuickSelectTimeMenu(); + } + + await this.closeQuickSelectTimeMenu(); + } + + public async resumeAutoRefresh() { + log.debug('resumeAutoRefresh'); + const refreshConfig = await this.getRefreshConfig(true); + if (refreshConfig.isPaused) { + log.debug('resume auto refresh'); + await testSubjects.click('superDatePickerToggleRefreshButton'); + } + + await this.closeQuickSelectTimeMenu(); + } + + public async setHistoricalDataRange() { + await this.setDefaultAbsoluteRange(); + } + + public async setDefaultDataRange() { + const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; + const toTime = 'Apr 13, 2018 @ 00:00:00.000'; + await this.setAbsoluteRange(fromTime, toTime); + } + + public async setLogstashDataRange() { + const fromTime = 'Apr 9, 2018 @ 00:00:00.000'; + const toTime = 'Apr 13, 2018 @ 00:00:00.000'; + await this.setAbsoluteRange(fromTime, toTime); + } + } + + return new TimePicker(); +} diff --git a/test/functional/page_objects/timelion_page.js b/test/functional/page_objects/timelion_page.js deleted file mode 100644 index 88eda5da5ce15..0000000000000 --- a/test/functional/page_objects/timelion_page.js +++ /dev/null @@ -1,93 +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. - */ - -export function TimelionPageProvider({ getService, getPageObjects }) { - const testSubjects = getService('testSubjects'); - const log = getService('log'); - const PageObjects = getPageObjects(['common', 'header']); - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); - - class TimelionPage { - async initTests() { - await kibanaServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - }); - - log.debug('load kibana index'); - await esArchiver.load('timelion'); - - await PageObjects.common.navigateToApp('timelion'); - } - - async setExpression(expression) { - const input = await testSubjects.find('timelionExpressionTextArea'); - await input.clearValue(); - await input.type(expression); - } - - async updateExpression(updates) { - const input = await testSubjects.find('timelionExpressionTextArea'); - await input.type(updates); - await PageObjects.common.sleep(500); - } - - async getExpression() { - const input = await testSubjects.find('timelionExpressionTextArea'); - return input.getVisibleText(); - } - - async getSuggestionItemsText() { - const elements = await testSubjects.findAll('timelionSuggestionListItem'); - return await Promise.all(elements.map(async element => await element.getVisibleText())); - } - - async clickSuggestion(suggestionIndex = 0, waitTime = 500) { - const elements = await testSubjects.findAll('timelionSuggestionListItem'); - if (suggestionIndex > elements.length) { - throw new Error( - `Unable to select suggestion ${suggestionIndex}, only ${elements.length} suggestions available.` - ); - } - await elements[suggestionIndex].click(); - // Wait for timelion expression to be updated after clicking suggestions - await PageObjects.common.sleep(waitTime); - } - - async saveTimelionSheet() { - await testSubjects.click('timelionSaveButton'); - await testSubjects.click('timelionSaveAsSheetButton'); - await testSubjects.click('timelionFinishSaveButton'); - await testSubjects.existOrFail('timelionSaveSuccessToast'); - await testSubjects.waitForDeleted('timelionSaveSuccessToast'); - } - - async expectWriteControls() { - await testSubjects.existOrFail('timelionSaveButton'); - await testSubjects.existOrFail('timelionDeleteButton'); - } - - async expectMissingWriteControls() { - await testSubjects.missingOrFail('timelionSaveButton'); - await testSubjects.missingOrFail('timelionDeleteButton'); - } - } - - return new TimelionPage(); -} diff --git a/test/functional/page_objects/timelion_page.ts b/test/functional/page_objects/timelion_page.ts new file mode 100644 index 0000000000000..1075c19a105c0 --- /dev/null +++ b/test/functional/page_objects/timelion_page.ts @@ -0,0 +1,95 @@ +/* + * 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 '../ftr_provider_context'; + +export function TimelionPageProvider({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const log = getService('log'); + const PageObjects = getPageObjects(['common', 'header']); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + class TimelionPage { + public async initTests() { + await kibanaServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + }); + + log.debug('load kibana index'); + await esArchiver.load('timelion'); + + await PageObjects.common.navigateToApp('timelion'); + } + + public async setExpression(expression: string) { + const input = await testSubjects.find('timelionExpressionTextArea'); + await input.clearValue(); + await input.type(expression); + } + + public async updateExpression(updates: string) { + const input = await testSubjects.find('timelionExpressionTextArea'); + await input.type(updates); + await PageObjects.common.sleep(500); + } + + public async getExpression() { + const input = await testSubjects.find('timelionExpressionTextArea'); + return input.getVisibleText(); + } + + public async getSuggestionItemsText() { + const elements = await testSubjects.findAll('timelionSuggestionListItem'); + return await Promise.all(elements.map(async element => await element.getVisibleText())); + } + + public async clickSuggestion(suggestionIndex = 0, waitTime = 500) { + const elements = await testSubjects.findAll('timelionSuggestionListItem'); + if (suggestionIndex > elements.length) { + throw new Error( + `Unable to select suggestion ${suggestionIndex}, only ${elements.length} suggestions available.` + ); + } + await elements[suggestionIndex].click(); + // Wait for timelion expression to be updated after clicking suggestions + await PageObjects.common.sleep(waitTime); + } + + public async saveTimelionSheet() { + await testSubjects.click('timelionSaveButton'); + await testSubjects.click('timelionSaveAsSheetButton'); + await testSubjects.click('timelionFinishSaveButton'); + await testSubjects.existOrFail('timelionSaveSuccessToast'); + await testSubjects.waitForDeleted('timelionSaveSuccessToast'); + } + + public async expectWriteControls() { + await testSubjects.existOrFail('timelionSaveButton'); + await testSubjects.existOrFail('timelionDeleteButton'); + } + + public async expectMissingWriteControls() { + await testSubjects.missingOrFail('timelionSaveButton'); + await testSubjects.missingOrFail('timelionDeleteButton'); + } + } + + return new TimelionPage(); +} diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index a10bb013b3af4..02ed9e9865d9a 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 0000000000000..30c7db87a8f8b --- /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 8ea8d2ff49e3b..9ae1021227315 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 e5f5faa6ac361..b47e84216dd16 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 0000000000000..69cadc7491eeb --- /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/vars/prChanges.groovy b/vars/prChanges.groovy index d7f46ee7be23e..4b9b20a945f76 100644 --- a/vars/prChanges.groovy +++ b/vars/prChanges.groovy @@ -7,6 +7,7 @@ def getSkippablePaths() { /^docs\//, /^rfcs\//, /^.ci\/.+\.yml$/, + /^.ci\/es-snapshots\//, /^\.github\//, /\.md$/, ] diff --git a/vars/slackNotifications.groovy b/vars/slackNotifications.groovy new file mode 100644 index 0000000000000..8ae37d1c44637 --- /dev/null +++ b/vars/slackNotifications.groovy @@ -0,0 +1,111 @@ +def getFailedBuildBlocks() { + def messages = [ + getFailedSteps(), + getTestFailures(), + ] + + return messages + .findAll { !!it } // No blank strings + .collect { markdownBlock(it) } +} + +def dividerBlock() { + return [ type: "divider" ] +} + +def markdownBlock(message) { + return [ + type: "section", + text: [ + type: "mrkdwn", + text: message, + ], + ] +} + +def contextBlock(message) { + return [ + type: "context", + elements: [ + [ + type: 'mrkdwn', + text: message, + ] + ] + ] +} + +def getFailedSteps() { + try { + def steps = jenkinsApi.getFailedSteps()?.findAll { step -> + step.displayName != 'Check out from version control' + } + + if (steps?.size() > 0) { + def list = steps.collect { "• <${it.logs}|${it.displayName}>" }.join("\n") + return "*Failed Steps*\n${list}" + } + } catch (ex) { + buildUtils.printStacktrace(ex) + print "Error retrieving failed pipeline steps for PR comment, will skip this section" + } + + return "" +} + +def getTestFailures() { + def failures = testUtils.getFailures() + if (!failures) { + return "" + } + + def messages = [] + messages << "*Test Failures*" + + def list = failures.collect { "• <${it.url}|${it.fullDisplayName}>" }.join("\n") + return "*Test Failures*\n${list}" +} + +def sendFailedBuild(Map params = [:]) { + def displayName = "${env.JOB_NAME} ${env.BUILD_DISPLAY_NAME}" + + def config = [ + channel: '#kibana-operations', + title: ":broken_heart: *<${env.BUILD_URL}|${displayName}>*", + message: ":broken_heart: ${displayName}", + color: 'danger', + icon: ':jenkins:', + username: 'Kibana Operations', + context: contextBlock("${displayName} · "), + ] + params + + def blocks = [markdownBlock(config.title)] + getFailedBuildBlocks().each { blocks << it } + blocks << dividerBlock() + blocks << config.context + + slackSend( + channel: config.channel, + username: config.username, + iconEmoji: config.icon, + color: config.color, + message: config.message, + blocks: blocks + ) +} + +def onFailure(Map options = [:], Closure closure) { + // try/finally will NOT work here, because the build status will not have been changed to ERROR when the finally{} block executes + catchError { + closure() + } + + def status = buildUtils.getBuildStatus() + if (status != "SUCCESS" && status != "UNSTABLE") { + catchErrors { + sendFailedBuild(options) + } + } +} + +return this diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 4acb170d12574..ccf8739dd9730 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", @@ -21,6 +23,7 @@ "xpack.indexLifecycleMgmt": "plugins/index_lifecycle_management", "xpack.infra": "plugins/infra", "xpack.ingestManager": "plugins/ingest_manager", + "xpack.ingestPipelines": "plugins/ingest_pipelines", "xpack.lens": "plugins/lens", "xpack.licenseMgmt": "plugins/license_management", "xpack.licensing": "plugins/licensing", diff --git a/x-pack/examples/ui_actions_enhanced_examples/README.md b/x-pack/examples/ui_actions_enhanced_examples/README.md index c9f53137d8687..ec049bbd33dec 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 f75852edced5c..e220cdd5cd297 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 0000000000000..47a3429b16d7a --- /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 0000000000000..b1e1040daee6e --- /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 0000000000000..69cf260a20a81 --- /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 0000000000000..cf379b29a0039 --- /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 0000000000000..ccd75e7dcc3e3 --- /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 0000000000000..b975a73e55621 --- /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 0000000000000..b975a73e55621 --- /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 0000000000000..518642866c2b5 --- /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 0000000000000..1213ec2f35995 --- /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 0000000000000..3e92a9f3f1fe4 --- /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 0000000000000..e824c49a6f1fa --- /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 0000000000000..5dfc250a56d28 --- /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 0000000000000..cc38386b26385 --- /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 a4c43753c8247..0d4f274caf57f 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/index.js b/x-pack/index.js index cfadddac3994a..99b63a49f5793 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -10,11 +10,8 @@ import { reporting } from './legacy/plugins/reporting'; import { security } from './legacy/plugins/security'; import { dashboardMode } from './legacy/plugins/dashboard_mode'; import { beats } from './legacy/plugins/beats_management'; -import { apm } from './legacy/plugins/apm'; import { maps } from './legacy/plugins/maps'; import { spaces } from './legacy/plugins/spaces'; -import { canvas } from './legacy/plugins/canvas'; -import { infra } from './legacy/plugins/infra'; import { taskManager } from './legacy/plugins/task_manager'; import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects'; import { ingestManager } from './legacy/plugins/ingest_manager'; @@ -28,10 +25,7 @@ module.exports = function(kibana) { security(kibana), dashboardMode(kibana), beats(kibana), - apm(kibana), maps(kibana), - canvas(kibana), - infra(kibana), taskManager(kibana), encryptedSavedObjects(kibana), ingestManager(kibana), diff --git a/x-pack/legacy/plugins/apm/.prettierrc b/x-pack/legacy/plugins/apm/.prettierrc deleted file mode 100644 index 650cb880f6f5a..0000000000000 --- a/x-pack/legacy/plugins/apm/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "singleQuote": true, - "semi": true -} diff --git a/x-pack/legacy/plugins/apm/e2e/README.md b/x-pack/legacy/plugins/apm/e2e/README.md deleted file mode 100644 index a891d64539a3f..0000000000000 --- a/x-pack/legacy/plugins/apm/e2e/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# End-To-End (e2e) Test for APM UI - -**Run E2E tests** - -```sh -x-pack/legacy/plugins/apm/e2e/run-e2e.sh -``` - -_Starts Kibana, APM Server, Elasticsearch (with sample data) and runs the tests_ - -## Reproducing CI builds - -> This process is very slow compared to the local development described above. Consider that the CI must install and configure the build tools and create a Docker image for the project to run tests in a consistent manner. - -The Jenkins CI uses a shell script to prepare Kibana: - -```shell -# Prepare and run Kibana locally -$ x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh -# Build Docker image for Kibana -$ docker build --tag cypress --build-arg NODE_VERSION=$(cat .node-version) x-pack/legacy/plugins/apm/e2e/ci -# Run Docker image -$ docker run --rm -t --user "$(id -u):$(id -g)" \ - -v `pwd`:/app --network="host" \ - --name cypress cypress -``` diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js deleted file mode 100644 index 968c2675a62e7..0000000000000 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - "APM": { - "Transaction duration charts": { - "1": "500 ms", - "2": "250 ms", - "3": "0 ms" - } - }, - "__version": "4.2.0" -} diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts deleted file mode 100644 index d2383acd45eba..0000000000000 --- a/x-pack/legacy/plugins/apm/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { Server } from 'hapi'; -import { resolve } from 'path'; -import { APMPluginContract } from '../../../plugins/apm/server'; -import { LegacyPluginInitializer } from '../../../../src/legacy/types'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; -import mappings from './mappings.json'; - -export const apm: LegacyPluginInitializer = kibana => { - return new kibana.Plugin({ - require: [ - 'kibana', - 'elasticsearch', - 'xpack_main', - 'apm_oss', - 'task_manager' - ], - id: 'apm', - configPrefix: 'xpack.apm', - publicDir: resolve(__dirname, 'public'), - uiExports: { - app: { - title: 'APM', - description: i18n.translate('xpack.apm.apmForESDescription', { - defaultMessage: 'APM for the Elastic Stack' - }), - main: 'plugins/apm/index', - icon: 'plugins/apm/icon.svg', - euiIconType: 'apmApp', - order: 8100, - category: DEFAULT_APP_CATEGORIES.observability - }, - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - home: ['plugins/apm/legacy_register_feature'], - - // TODO: get proper types - injectDefaultVars(server: Server) { - const config = server.config(); - return { - apmUiEnabled: config.get('xpack.apm.ui.enabled'), - // TODO: rename to apm_oss.indexPatternTitle in 7.0 (breaking change) - apmIndexPatternTitle: config.get('apm_oss.indexPattern'), - apmServiceMapEnabled: config.get('xpack.apm.serviceMapEnabled') - }; - }, - savedObjectSchemas: { - 'apm-services-telemetry': { - isNamespaceAgnostic: true - }, - 'apm-indices': { - isNamespaceAgnostic: true - } - }, - mappings - }, - - // TODO: get proper types - config(Joi: any) { - return Joi.object({ - // display menu item - ui: Joi.object({ - enabled: Joi.boolean().default(true), - transactionGroupBucketSize: Joi.number().default(100), - maxTraceItems: Joi.number().default(1000) - }).default(), - - // enable plugin - enabled: Joi.boolean().default(true), - - // index patterns - autocreateApmIndexPattern: Joi.boolean().default(true), - - // service map - serviceMapEnabled: Joi.boolean().default(true), - serviceMapFingerprintBucketSize: Joi.number().default(100), - serviceMapTraceIdBucketSize: Joi.number().default(65), - serviceMapFingerprintGlobalBucketSize: Joi.number().default(1000), - serviceMapTraceIdGlobalBucketSize: Joi.number().default(6), - serviceMapMaxTracesPerRequest: Joi.number().default(50), - - // telemetry - telemetryCollectionEnabled: Joi.boolean().default(true) - }).default(); - }, - - // TODO: get proper types - init(server: Server) { - server.plugins.xpack_main.registerFeature({ - id: 'apm', - name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', { - defaultMessage: 'APM' - }), - order: 900, - icon: 'apmApp', - navLinkId: 'apm', - app: ['apm', 'kibana'], - catalogue: ['apm'], - // see x-pack/plugins/features/common/feature_kibana_privileges.ts - privileges: { - all: { - app: ['apm', 'kibana'], - api: [ - 'apm', - 'apm_write', - 'actions-read', - 'actions-all', - 'alerting-read', - 'alerting-all' - ], - catalogue: ['apm'], - savedObject: { - all: ['alert', 'action', 'action_task_params'], - read: [] - }, - ui: [ - 'show', - 'save', - 'alerting:show', - 'actions:show', - 'alerting:save', - 'actions:save', - 'alerting:delete', - 'actions:delete' - ] - }, - read: { - app: ['apm', 'kibana'], - api: [ - 'apm', - 'actions-read', - 'actions-all', - 'alerting-read', - 'alerting-all' - ], - catalogue: ['apm'], - savedObject: { - all: ['alert', 'action', 'action_task_params'], - read: [] - }, - ui: [ - 'show', - 'alerting:show', - 'actions:show', - 'alerting:save', - 'actions:save', - 'alerting:delete', - 'actions:delete' - ] - } - } - }); - const apmPlugin = server.newPlatform.setup.plugins - .apm as APMPluginContract; - - apmPlugin.registerLegacyAPI({ - server - }); - } - }); -}; diff --git a/x-pack/legacy/plugins/apm/mappings.json b/x-pack/legacy/plugins/apm/mappings.json deleted file mode 100644 index 6ca9f13792085..0000000000000 --- a/x-pack/legacy/plugins/apm/mappings.json +++ /dev/null @@ -1,933 +0,0 @@ -{ - "apm-telemetry": { - "properties": { - "agents": { - "properties": { - "dotnet": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - } - } - } - } - }, - "go": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - } - } - } - } - }, - "java": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - } - } - } - } - }, - "js-base": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - } - } - } - } - }, - "nodejs": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - } - } - } - } - }, - "python": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - } - } - } - } - }, - "ruby": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - } - } - } - } - }, - "rum-js": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 1024 - }, - "name": { - "type": "keyword", - "ignore_above": 1024 - }, - "version": { - "type": "keyword", - "ignore_above": 1024 - } - } - } - } - } - } - } - } - }, - "counts": { - "properties": { - "agent_configuration": { - "properties": { - "all": { - "type": "long" - } - } - }, - "error": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "max_error_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "max_transaction_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "services": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "sourcemap": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "span": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "traces": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - } - } - }, - "cardinality": { - "properties": { - "user_agent": { - "properties": { - "original": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - }, - "transaction": { - "properties": { - "name": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - } - } - }, - "has_any_services": { - "type": "boolean" - }, - "indices": { - "properties": { - "all": { - "properties": { - "total": { - "properties": { - "docs": { - "properties": { - "count": { - "type": "long" - } - } - }, - "store": { - "properties": { - "size_in_bytes": { - "type": "long" - } - } - } - } - } - } - }, - "shards": { - "properties": { - "total": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "ml": { - "properties": { - "all_jobs_count": { - "type": "long" - } - } - } - } - }, - "retainment": { - "properties": { - "error": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "span": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "services_per_agent": { - "properties": { - "dotnet": { - "type": "long", - "null_value": 0 - }, - "go": { - "type": "long", - "null_value": 0 - }, - "java": { - "type": "long", - "null_value": 0 - }, - "js-base": { - "type": "long", - "null_value": 0 - }, - "nodejs": { - "type": "long", - "null_value": 0 - }, - "python": { - "type": "long", - "null_value": 0 - }, - "ruby": { - "type": "long", - "null_value": 0 - }, - "rum-js": { - "type": "long", - "null_value": 0 - } - } - }, - "tasks": { - "properties": { - "agent_configuration": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "agents": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "cardinality": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "groupings": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "indices_stats": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "processor_events": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "services": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "versions": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - } - } - }, - "version": { - "properties": { - "apm_server": { - "properties": { - "major": { - "type": "long" - }, - "minor": { - "type": "long" - }, - "patch": { - "type": "long" - } - } - } - } - } - } - }, - "apm-indices": { - "properties": { - "apm_oss.sourcemapIndices": { - "type": "keyword" - }, - "apm_oss.errorIndices": { - "type": "keyword" - }, - "apm_oss.onboardingIndices": { - "type": "keyword" - }, - "apm_oss.spanIndices": { - "type": "keyword" - }, - "apm_oss.transactionIndices": { - "type": "keyword" - }, - "apm_oss.metricsIndices": { - "type": "keyword" - } - } - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx deleted file mode 100644 index 68acaee4abe5d..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx +++ /dev/null @@ -1,124 +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 { render, fireEvent } from '@testing-library/react'; -import { shallow } from 'enzyme'; -import { APMIndicesPermission } from '../'; - -import * as hooks from '../../../../hooks/useFetcher'; -import { - expectTextsInDocument, - expectTextsNotInDocument -} from '../../../../utils/testHelpers'; -import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; - -describe('APMIndicesPermission', () => { - it('returns empty component when api status is loading', () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - status: hooks.FETCH_STATUS.LOADING - }); - const component = shallow(); - expect(component.isEmptyRender()).toBeTruthy(); - }); - it('returns empty component when api status is pending', () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - status: hooks.FETCH_STATUS.PENDING - }); - const component = shallow(); - expect(component.isEmptyRender()).toBeTruthy(); - }); - it('renders missing permission page', () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - status: hooks.FETCH_STATUS.SUCCESS, - data: { - 'apm-*': { read: false } - } - }); - const component = render( - - - - ); - expectTextsInDocument(component, [ - 'Missing permissions to access APM', - 'Dismiss', - 'apm-*' - ]); - }); - it('shows escape hatch button when at least one indice has read privileges', () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - status: hooks.FETCH_STATUS.SUCCESS, - data: { - 'apm-7.5.1-error-*': { read: false }, - 'apm-7.5.1-metric-*': { read: false }, - 'apm-7.5.1-transaction-*': { read: false }, - 'apm-7.5.1-span-*': { read: true } - } - }); - const component = render( - - - - ); - expectTextsInDocument(component, [ - 'Missing permissions to access APM', - 'apm-7.5.1-error-*', - 'apm-7.5.1-metric-*', - 'apm-7.5.1-transaction-*', - 'Dismiss' - ]); - expectTextsNotInDocument(component, ['apm-7.5.1-span-*']); - }); - - it('shows children component when indices have read privileges', () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - status: hooks.FETCH_STATUS.SUCCESS, - data: { - 'apm-7.5.1-error-*': { read: true }, - 'apm-7.5.1-metric-*': { read: true }, - 'apm-7.5.1-transaction-*': { read: true }, - 'apm-7.5.1-span-*': { read: true } - } - }); - const component = render( - - -

My amazing component

-
-
- ); - expectTextsNotInDocument(component, [ - 'Missing permissions to access APM', - 'apm-7.5.1-error-*', - 'apm-7.5.1-metric-*', - 'apm-7.5.1-transaction-*', - 'apm-7.5.1-span-*' - ]); - expectTextsInDocument(component, ['My amazing component']); - }); - - it('dismesses the warning by clicking on the escape hatch', () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - status: hooks.FETCH_STATUS.SUCCESS, - data: { - 'apm-7.5.1-error-*': { read: false }, - 'apm-7.5.1-metric-*': { read: false }, - 'apm-7.5.1-transaction-*': { read: false }, - 'apm-7.5.1-span-*': { read: true } - } - }); - const component = render( - - -

My amazing component

-
-
- ); - expectTextsInDocument(component, ['Dismiss']); - fireEvent.click(component.getByText('Dismiss')); - expectTextsInDocument(component, ['My amazing component']); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/index.tsx deleted file mode 100644 index 40e039dcd40c5..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/index.tsx +++ /dev/null @@ -1,163 +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 { - EuiButton, - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiPanel, - EuiText, - EuiTitle -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash'; -import React, { useState } from 'react'; -import styled from 'styled-components'; -import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; -import { fontSize, pct, px, units } from '../../../style/variables'; -import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; -import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink'; - -export const APMIndicesPermission: React.FC = ({ children }) => { - const [ - isPermissionWarningDismissed, - setIsPermissionWarningDismissed - ] = useState(false); - - const { data: indicesPrivileges = {}, status } = useFetcher(callApmApi => { - return callApmApi({ - pathname: '/api/apm/security/indices_privileges' - }); - }, []); - - // Return null until receive the reponse of the api. - if (status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING) { - return null; - } - - const indicesWithoutPermission = Object.keys(indicesPrivileges).filter( - index => !indicesPrivileges[index].read - ); - - // Show permission warning when a user has at least one index without Read privilege, - // and he has not manually dismissed the warning - if (!isEmpty(indicesWithoutPermission) && !isPermissionWarningDismissed) { - return ( - setIsPermissionWarningDismissed(true)} - /> - ); - } - - return <>{children}; -}; - -const CentralizedContainer = styled.div` - height: ${pct(100)}; - display: flex; - justify-content: center; - align-items: center; -`; - -const EscapeHatch = styled.div` - width: ${pct(100)}; - margin-top: ${px(units.plus)}; - justify-content: center; - display: flex; -`; - -interface Props { - indicesWithoutPermission: string[]; - onEscapeHatchClick: () => void; -} - -const PermissionWarning = ({ - indicesWithoutPermission, - onEscapeHatchClick -}: Props) => { - return ( -
- - - -

- {i18n.translate('xpack.apm.permission.apm', { - defaultMessage: 'APM' - })} -

-
-
- - - -
- -
- - - {i18n.translate('xpack.apm.permission.title', { - defaultMessage: 'Missing permissions to access APM' - })} - - } - body={ - <> -

- {i18n.translate('xpack.apm.permission.description', { - defaultMessage: - "Your user doesn't have access to all APM indices. You can still use the APM app but some data may be missing. You must be granted access to the following indices:" - })} -

-
    - {indicesWithoutPermission.map(index => ( -
  • - {index} -
  • - ))} -
- - } - actions={ - <> - - {(href: string) => ( - - {i18n.translate('xpack.apm.permission.learnMore', { - defaultMessage: 'Learn more about APM permissions' - })} - - )} - - - - {i18n.translate('xpack.apm.permission.dismissWarning', { - defaultMessage: 'Dismiss' - })} - - - - } - /> -
-
-
-
- ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx deleted file mode 100644 index 490bf472065e3..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx +++ /dev/null @@ -1,209 +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 { - EuiButtonEmpty, - EuiPanel, - EuiSpacer, - EuiTab, - EuiTabs, - EuiTitle, - EuiIcon, - EuiToolTip -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; -import React from 'react'; -import styled from 'styled-components'; -import { first } from 'lodash'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ErrorGroupAPIResponse } from '../../../../../../../../plugins/apm/server/lib/errors/get_error_group'; -import { APMError } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; -import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -import { px, unit, units } from '../../../../style/variables'; -import { DiscoverErrorLink } from '../../../shared/Links/DiscoverLinks/DiscoverErrorLink'; -import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; -import { history } from '../../../../utils/history'; -import { ErrorMetadata } from '../../../shared/MetadataTable/ErrorMetadata'; -import { Stacktrace } from '../../../shared/Stacktrace'; -import { - ErrorTab, - exceptionStacktraceTab, - getTabs, - logStacktraceTab -} from './ErrorTabs'; -import { Summary } from '../../../shared/Summary'; -import { TimestampTooltip } from '../../../shared/TimestampTooltip'; -import { HttpInfoSummaryItem } from '../../../shared/Summary/HttpInfoSummaryItem'; -import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink'; -import { UserAgentSummaryItem } from '../../../shared/Summary/UserAgentSummaryItem'; -import { ExceptionStacktrace } from './ExceptionStacktrace'; - -const HeaderContainer = styled.div` - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: ${px(unit)}; -`; - -const TransactionLinkName = styled.div` - margin-left: ${px(units.half)}; - display: inline-block; - vertical-align: middle; -`; - -interface Props { - errorGroup: ErrorGroupAPIResponse; - urlParams: IUrlParams; - location: Location; -} - -// TODO: Move query-string-based tabs into a re-usable component? -function getCurrentTab( - tabs: ErrorTab[] = [], - currentTabKey: string | undefined -) { - const selectedTab = tabs.find(({ key }) => key === currentTabKey); - return selectedTab ? selectedTab : first(tabs) || {}; -} - -export function DetailView({ errorGroup, urlParams, location }: Props) { - const { transaction, error, occurrencesCount } = errorGroup; - - if (!error) { - return null; - } - - const tabs = getTabs(error); - const currentTab = getCurrentTab(tabs, urlParams.detailTab); - - const errorUrl = error.error.page?.url || error.url?.full; - - const method = error.http?.request?.method; - const status = error.http?.response?.status_code; - - return ( - - - -

- {i18n.translate( - 'xpack.apm.errorGroupDetails.errorOccurrenceTitle', - { - defaultMessage: 'Error occurrence' - } - )} -

-
- - - {i18n.translate( - 'xpack.apm.errorGroupDetails.viewOccurrencesInDiscoverButtonLabel', - { - defaultMessage: - 'View {occurrencesCount} {occurrencesCount, plural, one {occurrence} other {occurrences}} in Discover.', - values: { occurrencesCount } - } - )} - - -
- - , - errorUrl && method ? ( - - ) : null, - transaction && transaction.user_agent ? ( - - ) : null, - transaction && ( - - - - - {transaction.transaction.name} - - - - ) - ]} - /> - - - - - {tabs.map(({ key, label }) => { - return ( - { - history.replace({ - ...location, - search: fromQuery({ - ...toQuery(location.search), - detailTab: key - }) - }); - }} - isSelected={currentTab.key === key} - key={key} - > - {label} - - ); - })} - - - - - ); -} - -function TabContent({ - error, - currentTab -}: { - error: APMError; - currentTab: ErrorTab; -}) { - const codeLanguage = error.service.language?.name; - const exceptions = error.error.exception || []; - const logStackframes = error.error.log?.stacktrace; - - switch (currentTab.key) { - case logStacktraceTab.key: - return ( - - ); - case exceptionStacktraceTab.key: - return ( - - ); - default: - return ; - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx deleted file mode 100644 index ccd720ceee075..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ /dev/null @@ -1,209 +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 { - EuiBadge, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiSpacer, - EuiText, - EuiTitle -} from '@elastic/eui'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import styled from 'styled-components'; -import { NOT_AVAILABLE_LABEL } from '../../../../../../../plugins/apm/common/i18n'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { DetailView } from './DetailView'; -import { ErrorDistribution } from './Distribution'; -import { useLocation } from '../../../hooks/useLocation'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useTrackPageview } from '../../../../../../../plugins/observability/public'; - -const Titles = styled.div` - margin-bottom: ${px(units.plus)}; -`; - -const Label = styled.div` - margin-bottom: ${px(units.quarter)}; - font-size: ${fontSizes.small}; - color: ${theme.euiColorMediumShade}; -`; - -const Message = styled.div` - font-family: ${fontFamilyCode}; - font-weight: bold; - font-size: ${fontSizes.large}; - margin-bottom: ${px(units.half)}; -`; - -const Culprit = styled.div` - font-family: ${fontFamilyCode}; -`; - -function getShortGroupId(errorGroupId?: string) { - if (!errorGroupId) { - return NOT_AVAILABLE_LABEL; - } - - return errorGroupId.slice(0, 5); -} - -export function ErrorGroupDetails() { - const location = useLocation(); - const { urlParams, uiFilters } = useUrlParams(); - const { serviceName, start, end, errorGroupId } = urlParams; - - const { data: errorGroupData } = useFetcher( - callApmApi => { - if (serviceName && start && end && errorGroupId) { - return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors/{groupId}', - params: { - path: { - serviceName, - groupId: errorGroupId - }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters) - } - } - }); - } - }, - [serviceName, start, end, errorGroupId, uiFilters] - ); - - const { data: errorDistributionData } = useFetcher( - callApmApi => { - if (serviceName && start && end && errorGroupId) { - return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors/distribution', - params: { - path: { - serviceName - }, - query: { - start, - end, - groupId: errorGroupId, - uiFilters: JSON.stringify(uiFilters) - } - } - }); - } - }, - [serviceName, start, end, errorGroupId, uiFilters] - ); - - useTrackPageview({ app: 'apm', path: 'error_group_details' }); - useTrackPageview({ app: 'apm', path: 'error_group_details', delay: 15000 }); - - if (!errorGroupData || !errorDistributionData) { - return null; - } - - // If there are 0 occurrences, show only distribution chart w. empty message - const showDetails = errorGroupData.occurrencesCount !== 0; - const logMessage = errorGroupData.error?.error.log?.message; - const excMessage = errorGroupData.error?.error.exception?.[0].message; - const culprit = errorGroupData.error?.error.culprit; - const isUnhandled = - errorGroupData.error?.error.exception?.[0].handled === false; - - return ( -
- - - - -

- {i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', { - defaultMessage: 'Error group {errorGroupId}', - values: { - errorGroupId: getShortGroupId(urlParams.errorGroupId) - } - })} -

-
-
- {isUnhandled && ( - - - {i18n.translate('xpack.apm.errorGroupDetails.unhandledLabel', { - defaultMessage: 'Unhandled' - })} - - - )} -
-
- - - - - {showDetails && ( - - - {logMessage && ( - - - {logMessage} - - )} - - {excMessage || NOT_AVAILABLE_LABEL} - - {culprit || NOT_AVAILABLE_LABEL} - - - )} - - - - - {showDetails && ( - - )} -
- ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx deleted file mode 100644 index 250b9a5d188d0..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx +++ /dev/null @@ -1,199 +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 { EuiBadge, EuiToolTip } from '@elastic/eui'; -import numeral from '@elastic/numeral'; -import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; -import styled from 'styled-components'; -import { NOT_AVAILABLE_LABEL } from '../../../../../../../../plugins/apm/common/i18n'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ErrorGroupListAPIResponse } from '../../../../../../../../plugins/apm/server/lib/errors/get_error_groups'; -import { - fontFamilyCode, - fontSizes, - px, - truncate, - unit -} from '../../../../style/variables'; -import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { ManagedTable } from '../../../shared/ManagedTable'; -import { ErrorDetailLink } from '../../../shared/Links/apm/ErrorDetailLink'; -import { TimestampTooltip } from '../../../shared/TimestampTooltip'; -import { ErrorOverviewLink } from '../../../shared/Links/apm/ErrorOverviewLink'; -import { APMQueryParams } from '../../../shared/Links/url_helpers'; - -const GroupIdLink = styled(ErrorDetailLink)` - font-family: ${fontFamilyCode}; -`; - -const MessageAndCulpritCell = styled.div` - ${truncate('100%')}; -`; - -const ErrorLink = styled(ErrorOverviewLink)` - ${truncate('100%')}; -`; - -const MessageLink = styled(ErrorDetailLink)` - font-family: ${fontFamilyCode}; - font-size: ${fontSizes.large}; - ${truncate('100%')}; -`; - -const Culprit = styled.div` - font-family: ${fontFamilyCode}; -`; - -interface Props { - items: ErrorGroupListAPIResponse; -} - -const ErrorGroupList: React.FC = props => { - const { items } = props; - const { urlParams } = useUrlParams(); - const { serviceName } = urlParams; - - if (!serviceName) { - throw new Error('Service name is required'); - } - - const columns = useMemo( - () => [ - { - name: i18n.translate('xpack.apm.errorsTable.groupIdColumnLabel', { - defaultMessage: 'Group ID' - }), - field: 'groupId', - sortable: false, - width: px(unit * 6), - render: (groupId: string) => { - return ( - - {groupId.slice(0, 5) || NOT_AVAILABLE_LABEL} - - ); - } - }, - { - name: i18n.translate('xpack.apm.errorsTable.typeColumnLabel', { - defaultMessage: 'Type' - }), - field: 'type', - sortable: false, - render: (type: string, item: ErrorGroupListAPIResponse[0]) => { - return ( - - {type} - - ); - } - }, - { - name: i18n.translate( - 'xpack.apm.errorsTable.errorMessageAndCulpritColumnLabel', - { - defaultMessage: 'Error message and culprit' - } - ), - field: 'message', - sortable: false, - width: '50%', - render: (message: string, item: ErrorGroupListAPIResponse[0]) => { - return ( - - - - {message || NOT_AVAILABLE_LABEL} - - -
- - {item.culprit || NOT_AVAILABLE_LABEL} - -
- ); - } - }, - { - name: '', - field: 'handled', - sortable: false, - align: 'right', - render: (isUnhandled: boolean) => - isUnhandled === false && ( - - {i18n.translate('xpack.apm.errorsTable.unhandledLabel', { - defaultMessage: 'Unhandled' - })} - - ) - }, - { - name: i18n.translate('xpack.apm.errorsTable.occurrencesColumnLabel', { - defaultMessage: 'Occurrences' - }), - field: 'occurrenceCount', - sortable: true, - dataType: 'number', - render: (value?: number) => - value ? numeral(value).format('0.[0]a') : NOT_AVAILABLE_LABEL - }, - { - field: 'latestOccurrenceAt', - sortable: true, - name: i18n.translate( - 'xpack.apm.errorsTable.latestOccurrenceColumnLabel', - { - defaultMessage: 'Latest occurrence' - } - ), - align: 'right', - render: (value?: number) => - value ? ( - - ) : ( - NOT_AVAILABLE_LABEL - ) - } - ], - [serviceName, urlParams] - ); - - return ( - - ); -}; - -export { ErrorGroupList }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx deleted file mode 100644 index 8c5a4545f1043..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx +++ /dev/null @@ -1,137 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiSpacer, - EuiTitle -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; -import { ErrorGroupList } from './List'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useTrackPageview } from '../../../../../../../plugins/observability/public'; -import { PROJECTION } from '../../../../../../../plugins/apm/common/projections/typings'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; - -const ErrorGroupOverview: React.FC = () => { - const { urlParams, uiFilters } = useUrlParams(); - - const { serviceName, start, end, sortField, sortDirection } = urlParams; - - const { data: errorDistributionData } = useFetcher( - callApmApi => { - if (serviceName && start && end) { - return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors/distribution', - params: { - path: { - serviceName - }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters) - } - } - }); - } - }, - [serviceName, start, end, uiFilters] - ); - - const { data: errorGroupListData } = useFetcher( - callApmApi => { - const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; - - if (serviceName && start && end) { - return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors', - params: { - path: { - serviceName - }, - query: { - start, - end, - sortField, - sortDirection: normalizedSortDirection, - uiFilters: JSON.stringify(uiFilters) - } - } - }); - } - }, - [serviceName, start, end, sortField, sortDirection, uiFilters] - ); - - useTrackPageview({ - app: 'apm', - path: 'error_group_overview' - }); - useTrackPageview({ app: 'apm', path: 'error_group_overview', delay: 15000 }); - - const localUIFiltersConfig = useMemo(() => { - const config: React.ComponentProps = { - filterNames: ['host', 'containerId', 'podName', 'serviceVersion'], - params: { - serviceName - }, - projection: PROJECTION.ERROR_GROUPS - }; - - return config; - }, [serviceName]); - - if (!errorDistributionData || !errorGroupListData) { - return null; - } - - return ( - <> - - - - - - - - - - - - - - - - - - -

Errors

-
- - - -
-
-
- - ); -}; - -export { ErrorGroupOverview }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx deleted file mode 100644 index c87e56fe9eff6..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx +++ /dev/null @@ -1,253 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { Redirect, RouteComponentProps } from 'react-router-dom'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../../../../../plugins/apm/common/service_nodes'; -import { ErrorGroupDetails } from '../../ErrorGroupDetails'; -import { ServiceDetails } from '../../ServiceDetails'; -import { TransactionDetails } from '../../TransactionDetails'; -import { Home } from '../../Home'; -import { BreadcrumbRoute } from '../ProvideBreadcrumbs'; -import { RouteName } from './route_names'; -import { Settings } from '../../Settings'; -import { AgentConfigurations } from '../../Settings/AgentConfigurations'; -import { ApmIndices } from '../../Settings/ApmIndices'; -import { toQuery } from '../../../shared/Links/url_helpers'; -import { ServiceNodeMetrics } from '../../ServiceNodeMetrics'; -import { resolveUrlParams } from '../../../../context/UrlParamsContext/resolveUrlParams'; -import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../../../../plugins/apm/common/i18n'; -import { TraceLink } from '../../TraceLink'; -import { CustomizeUI } from '../../Settings/CustomizeUI'; -import { - EditAgentConfigurationRouteHandler, - CreateAgentConfigurationRouteHandler -} from './route_handlers/agent_configuration'; - -const metricsBreadcrumb = i18n.translate('xpack.apm.breadcrumb.metricsTitle', { - defaultMessage: 'Metrics' -}); - -interface RouteParams { - serviceName: string; -} - -const renderAsRedirectTo = (to: string) => { - return ({ location }: RouteComponentProps) => ( - - ); -}; - -export const routes: BreadcrumbRoute[] = [ - { - exact: true, - path: '/', - render: renderAsRedirectTo('/services'), - breadcrumb: 'APM', - name: RouteName.HOME - }, - { - exact: true, - path: '/services', - component: () => , - breadcrumb: i18n.translate('xpack.apm.breadcrumb.servicesTitle', { - defaultMessage: 'Services' - }), - name: RouteName.SERVICES - }, - { - exact: true, - path: '/traces', - component: () => , - breadcrumb: i18n.translate('xpack.apm.breadcrumb.tracesTitle', { - defaultMessage: 'Traces' - }), - name: RouteName.TRACES - }, - { - exact: true, - path: '/settings', - render: renderAsRedirectTo('/settings/agent-configuration'), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.listSettingsTitle', { - defaultMessage: 'Settings' - }), - name: RouteName.SETTINGS - }, - { - exact: true, - path: '/settings/apm-indices', - component: () => ( - - - - ), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.indicesTitle', { - defaultMessage: 'Indices' - }), - name: RouteName.INDICES - }, - { - exact: true, - path: '/settings/agent-configuration', - component: () => ( - - - - ), - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.agentConfigurationTitle', - { defaultMessage: 'Agent Configuration' } - ), - name: RouteName.AGENT_CONFIGURATION - }, - - { - exact: true, - path: '/settings/agent-configuration/create', - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.createAgentConfigurationTitle', - { defaultMessage: 'Create Agent Configuration' } - ), - name: RouteName.AGENT_CONFIGURATION_CREATE, - component: () => - }, - { - exact: true, - path: '/settings/agent-configuration/edit', - breadcrumb: i18n.translate( - 'xpack.apm.breadcrumb.settings.editAgentConfigurationTitle', - { defaultMessage: 'Edit Agent Configuration' } - ), - name: RouteName.AGENT_CONFIGURATION_EDIT, - component: () => - }, - { - exact: true, - path: '/services/:serviceName', - breadcrumb: ({ match }) => match.params.serviceName, - render: (props: RouteComponentProps) => - renderAsRedirectTo( - `/services/${props.match.params.serviceName}/transactions` - )(props), - name: RouteName.SERVICE - }, - // errors - { - exact: true, - path: '/services/:serviceName/errors/:groupId', - component: ErrorGroupDetails, - breadcrumb: ({ match }) => match.params.groupId, - name: RouteName.ERROR - }, - { - exact: true, - path: '/services/:serviceName/errors', - component: () => , - breadcrumb: i18n.translate('xpack.apm.breadcrumb.errorsTitle', { - defaultMessage: 'Errors' - }), - name: RouteName.ERRORS - }, - // transactions - { - exact: true, - path: '/services/:serviceName/transactions', - component: () => , - breadcrumb: i18n.translate('xpack.apm.breadcrumb.transactionsTitle', { - defaultMessage: 'Transactions' - }), - name: RouteName.TRANSACTIONS - }, - // metrics - { - exact: true, - path: '/services/:serviceName/metrics', - component: () => , - breadcrumb: metricsBreadcrumb, - name: RouteName.METRICS - }, - // service nodes, only enabled for java agents for now - { - exact: true, - path: '/services/:serviceName/nodes', - component: () => , - breadcrumb: i18n.translate('xpack.apm.breadcrumb.nodesTitle', { - defaultMessage: 'JVMs' - }), - name: RouteName.SERVICE_NODES - }, - // node metrics - { - exact: true, - path: '/services/:serviceName/nodes/:serviceNodeName/metrics', - component: () => , - breadcrumb: ({ location }) => { - const { serviceNodeName } = resolveUrlParams(location, {}); - - if (serviceNodeName === SERVICE_NODE_NAME_MISSING) { - return UNIDENTIFIED_SERVICE_NODES_LABEL; - } - - return serviceNodeName || ''; - }, - name: RouteName.SERVICE_NODE_METRICS - }, - { - exact: true, - path: '/services/:serviceName/transactions/view', - component: TransactionDetails, - breadcrumb: ({ location }) => { - const query = toQuery(location.search); - return query.transactionName as string; - }, - name: RouteName.TRANSACTION_NAME - }, - { - exact: true, - path: '/link-to/trace/:traceId', - component: TraceLink, - breadcrumb: null, - name: RouteName.LINK_TO_TRACE - }, - - { - exact: true, - path: '/service-map', - component: () => , - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { - defaultMessage: 'Service Map' - }), - name: RouteName.SERVICE_MAP - }, - { - exact: true, - path: '/services/:serviceName/service-map', - component: () => , - breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { - defaultMessage: 'Service Map' - }), - name: RouteName.SINGLE_SERVICE_MAP - }, - { - exact: true, - path: '/settings/customize-ui', - component: () => ( - - - - ), - breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.customizeUI', { - defaultMessage: 'Customize UI' - }), - name: RouteName.CUSTOMIZE_UI - } -]; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx deleted file mode 100644 index 7e8d057a7be6c..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx +++ /dev/null @@ -1,30 +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 { AlertType } from '../../../../../../../../../plugins/apm/common/alert_types'; -import { AlertAdd } from '../../../../../../../../../plugins/triggers_actions_ui/public'; - -type AlertAddProps = React.ComponentProps; - -interface Props { - addFlyoutVisible: AlertAddProps['addFlyoutVisible']; - setAddFlyoutVisibility: AlertAddProps['setAddFlyoutVisibility']; - alertType: AlertType | null; -} - -export function AlertingFlyout(props: Props) { - const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; - - return alertType ? ( - - ) : null; -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx deleted file mode 100644 index 92b325ab00d35..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx +++ /dev/null @@ -1,146 +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 { - EuiButtonEmpty, - EuiContextMenu, - EuiPopover, - EuiContextMenuPanelDescriptor -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; -import { AlertType } from '../../../../../../../../plugins/apm/common/alert_types'; -import { AlertingFlyout } from './AlertingFlyout'; -import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; - -const alertLabel = i18n.translate( - 'xpack.apm.serviceDetails.alertsMenu.alerts', - { - defaultMessage: 'Alerts' - } -); - -const createThresholdAlertLabel = i18n.translate( - 'xpack.apm.serviceDetails.alertsMenu.createThresholdAlert', - { - defaultMessage: 'Create threshold alert' - } -); - -const CREATE_THRESHOLD_ALERT_PANEL_ID = 'create_threshold'; - -interface Props { - canReadAlerts: boolean; - canSaveAlerts: boolean; -} - -export function AlertIntegrations(props: Props) { - const { canSaveAlerts, canReadAlerts } = props; - - const plugin = useApmPluginContext(); - - const [popoverOpen, setPopoverOpen] = useState(false); - - const [alertType, setAlertType] = useState(null); - - const button = ( - setPopoverOpen(true)} - > - {i18n.translate('xpack.apm.serviceDetails.alertsMenu.alerts', { - defaultMessage: 'Alerts' - })} - - ); - - const panels: EuiContextMenuPanelDescriptor[] = [ - { - id: 0, - title: alertLabel, - items: [ - ...(canSaveAlerts - ? [ - { - name: createThresholdAlertLabel, - panel: CREATE_THRESHOLD_ALERT_PANEL_ID, - icon: 'bell' - } - ] - : []), - ...(canReadAlerts - ? [ - { - name: i18n.translate( - 'xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts', - { - defaultMessage: 'View active alerts' - } - ), - href: plugin.core.http.basePath.prepend( - '/app/kibana#/management/kibana/triggersActions/alerts' - ), - icon: 'tableOfContents' - } - ] - : []) - ] - }, - { - id: CREATE_THRESHOLD_ALERT_PANEL_ID, - title: createThresholdAlertLabel, - items: [ - { - name: i18n.translate( - 'xpack.apm.serviceDetails.alertsMenu.transactionDuration', - { - defaultMessage: 'Transaction duration' - } - ), - onClick: () => { - setAlertType(AlertType.TransactionDuration); - } - }, - { - name: i18n.translate( - 'xpack.apm.serviceDetails.alertsMenu.errorRate', - { - defaultMessage: 'Error rate' - } - ), - onClick: () => { - setAlertType(AlertType.ErrorRate); - } - } - ] - } - ]; - - return ( - <> - setPopoverOpen(false)} - panelPaddingSize="none" - anchorPosition="downRight" - > - - - { - if (!visible) { - setAlertType(null); - } - }} - /> - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx deleted file mode 100644 index cc5c62e25b491..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import React, { Component } from 'react'; -import { toMountPoint } from '../../../../../../../../../../src/plugins/kibana_react/public'; -import { startMLJob } from '../../../../../services/rest/ml'; -import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; -import { MLJobLink } from '../../../../shared/Links/MachineLearningLinks/MLJobLink'; -import { MachineLearningFlyoutView } from './view'; -import { ApmPluginContext } from '../../../../../context/ApmPluginContext'; - -interface Props { - isOpen: boolean; - onClose: () => void; - urlParams: IUrlParams; -} - -interface State { - isCreatingJob: boolean; -} - -export class MachineLearningFlyout extends Component { - static contextType = ApmPluginContext; - - public state: State = { - isCreatingJob: false - }; - - public onClickCreate = async ({ - transactionType - }: { - transactionType: string; - }) => { - this.setState({ isCreatingJob: true }); - try { - const { http } = this.context.core; - const { serviceName } = this.props.urlParams; - if (!serviceName) { - throw new Error('Service name is required to create this ML job'); - } - const res = await startMLJob({ http, serviceName, transactionType }); - const didSucceed = res.datafeeds[0].success && res.jobs[0].success; - if (!didSucceed) { - throw new Error('Creating ML job failed'); - } - this.addSuccessToast({ transactionType }); - } catch (e) { - this.addErrorToast(); - } - - this.setState({ isCreatingJob: false }); - this.props.onClose(); - }; - - public addErrorToast = () => { - const { core } = this.context; - - const { urlParams } = this.props; - const { serviceName } = urlParams; - - if (!serviceName) { - return; - } - - core.notifications.toasts.addWarning({ - title: i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle', - { - defaultMessage: 'Job creation failed' - } - ), - text: toMountPoint( -

- {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText', - { - defaultMessage: - 'Your current license may not allow for creating machine learning jobs, or this job may already exist.' - } - )} -

- ) - }); - }; - - public addSuccessToast = ({ - transactionType - }: { - transactionType: string; - }) => { - const { core } = this.context; - const { urlParams } = this.props; - const { serviceName } = urlParams; - - if (!serviceName) { - return; - } - - core.notifications.toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle', - { - defaultMessage: 'Job successfully created' - } - ), - text: toMountPoint( -

- {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText', - { - defaultMessage: - 'The analysis is now running for {serviceName} ({transactionType}). It might take a while before results are added to the response times graph.', - values: { - serviceName, - transactionType - } - } - )}{' '} - - - {i18n.translate( - 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText', - { - defaultMessage: 'View job' - } - )} - - -

- ) - }); - }; - - public render() { - const { isOpen, onClose, urlParams } = this.props; - const { serviceName } = urlParams; - const { isCreatingJob } = this.state; - - if (!isOpen || !serviceName) { - return null; - } - - return ( - - ); - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx deleted file mode 100644 index 102b135f3cd1f..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx +++ /dev/null @@ -1,119 +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 { EuiPopover } from '@elastic/eui'; -import cytoscape from 'cytoscape'; -import React, { - CSSProperties, - useCallback, - useContext, - useEffect, - useRef, - useState -} from 'react'; -import { SERVICE_NAME } from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; -import { CytoscapeContext } from '../Cytoscape'; -import { Contents } from './Contents'; -import { animationOptions } from '../cytoscapeOptions'; - -interface PopoverProps { - focusedServiceName?: string; -} - -export function Popover({ focusedServiceName }: PopoverProps) { - const cy = useContext(CytoscapeContext); - const [selectedNode, setSelectedNode] = useState< - cytoscape.NodeSingular | undefined - >(undefined); - const deselect = useCallback(() => { - if (cy) { - cy.elements().unselect(); - } - setSelectedNode(undefined); - }, [cy, setSelectedNode]); - const renderedHeight = selectedNode?.renderedHeight() ?? 0; - const renderedWidth = selectedNode?.renderedWidth() ?? 0; - const { x, y } = selectedNode?.renderedPosition() ?? { x: -10000, y: -10000 }; - const isOpen = !!selectedNode; - const isService = selectedNode?.data(SERVICE_NAME) !== undefined; - const triggerStyle: CSSProperties = { - background: 'transparent', - height: renderedHeight, - position: 'absolute', - width: renderedWidth - }; - const trigger =
; - const zoom = cy?.zoom() ?? 1; - const height = selectedNode?.height() ?? 0; - const translateY = y - ((zoom + 1) * height) / 4; - const popoverStyle: CSSProperties = { - position: 'absolute', - transform: `translate(${x}px, ${translateY}px)` - }; - const selectedNodeData = selectedNode?.data() ?? {}; - const selectedNodeServiceName = selectedNodeData.id; - const label = selectedNodeData.label || selectedNodeServiceName; - const popoverRef = useRef(null); - - // Set up Cytoscape event handlers - useEffect(() => { - const selectHandler: cytoscape.EventHandler = event => { - setSelectedNode(event.target); - }; - - if (cy) { - cy.on('select', 'node', selectHandler); - cy.on('unselect', 'node', deselect); - cy.on('data viewport', deselect); - } - - return () => { - if (cy) { - cy.removeListener('select', 'node', selectHandler); - cy.removeListener('unselect', 'node', deselect); - cy.removeListener('data viewport', undefined, deselect); - } - }; - }, [cy, deselect]); - - // Handle positioning of popover. This makes it so the popover positions - // itself correctly and the arrows are always pointing to where they should. - useEffect(() => { - if (popoverRef.current) { - popoverRef.current.positionPopoverFluid(); - } - }, [popoverRef, x, y]); - - const centerSelectedNode = useCallback(() => { - if (cy) { - cy.animate({ - ...animationOptions, - center: { eles: cy.getElementById(selectedNodeServiceName) } - }); - } - }, [cy, selectedNodeServiceName]); - - const isAlreadyFocused = focusedServiceName === selectedNodeServiceName; - - return ( - {}} - isOpen={isOpen} - ref={popoverRef} - style={popoverStyle} - > - - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx deleted file mode 100644 index d93caa601f0b6..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { render } from '@testing-library/react'; -import React, { FunctionComponent } from 'react'; -import { License } from '../../../../../../../plugins/licensing/common/license'; -import { LicenseContext } from '../../../context/LicenseContext'; -import { ServiceMap } from './'; -import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; - -const expiredLicense = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 0, - mode: 'platinum', - status: 'expired', - type: 'platinum', - uid: '1' - } -}); - -const Wrapper: FunctionComponent = ({ children }) => { - return ( - - {children} - - ); -}; - -describe('ServiceMap', () => { - describe('with an inactive license', () => { - it('renders the license banner', async () => { - expect( - ( - await render(, { - wrapper: Wrapper - }).findAllByText(/Platinum/) - ).length - ).toBeGreaterThan(0); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx deleted file mode 100644 index 94e42f1b91160..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import React from 'react'; -import { - invalidLicenseMessage, - isValidPlatinumLicense -} from '../../../../../../../plugins/apm/common/service_map'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { useLicense } from '../../../hooks/useLicense'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { LicensePrompt } from '../../shared/LicensePrompt'; -import { Controls } from './Controls'; -import { Cytoscape } from './Cytoscape'; -import { cytoscapeDivStyle } from './cytoscapeOptions'; -import { EmptyBanner } from './EmptyBanner'; -import { Popover } from './Popover'; -import { useRefDimensions } from './useRefDimensions'; -import { BetaBadge } from './BetaBadge'; -import { useTrackPageview } from '../../../../../../../plugins/observability/public'; - -interface ServiceMapProps { - serviceName?: string; -} - -export function ServiceMap({ serviceName }: ServiceMapProps) { - const license = useLicense(); - const { urlParams } = useUrlParams(); - - const { data = { elements: [] } } = useFetcher(() => { - // When we don't have a license or a valid license, don't make the request. - if (!license || !isValidPlatinumLicense(license)) { - return; - } - - const { start, end, environment } = urlParams; - if (start && end) { - return callApmApi({ - isCachable: false, - pathname: '/api/apm/service-map', - params: { - query: { - start, - end, - environment, - serviceName - } - } - }); - } - }, [license, serviceName, urlParams]); - - const { ref, height, width } = useRefDimensions(); - - useTrackPageview({ app: 'apm', path: 'service_map' }); - useTrackPageview({ app: 'apm', path: 'service_map', delay: 15000 }); - - if (!license) { - return null; - } - - return isValidPlatinumLicense(license) ? ( -
- - - - {serviceName && } - - -
- ) : ( - - - - - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMetrics/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMetrics/index.tsx deleted file mode 100644 index 060e635e83549..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMetrics/index.tsx +++ /dev/null @@ -1,69 +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 { - EuiFlexGrid, - EuiFlexItem, - EuiPanel, - EuiSpacer, - EuiFlexGroup -} from '@elastic/eui'; -import React, { useMemo } from 'react'; -import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; -import { MetricsChart } from '../../shared/charts/MetricsChart'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; -import { PROJECTION } from '../../../../../../../plugins/apm/common/projections/typings'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; - -interface ServiceMetricsProps { - agentName: string; -} - -export function ServiceMetrics({ agentName }: ServiceMetricsProps) { - const { urlParams } = useUrlParams(); - const { serviceName, serviceNodeName } = urlParams; - const { data } = useServiceMetricCharts(urlParams, agentName); - const { start, end } = urlParams; - - const localFiltersConfig: React.ComponentProps = useMemo( - () => ({ - filterNames: ['host', 'containerId', 'podName', 'serviceVersion'], - params: { - serviceName, - serviceNodeName - }, - projection: PROJECTION.METRICS, - showCount: false - }), - [serviceName, serviceNodeName] - ); - - return ( - <> - - - - - - - - - {data.charts.map(chart => ( - - - - - - ))} - - - - - - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx deleted file mode 100644 index 2bf26946932ea..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx +++ /dev/null @@ -1,186 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiHorizontalRule, - EuiFlexGrid, - EuiPanel, - EuiSpacer, - EuiStat, - EuiToolTip, - EuiCallOut -} from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../../../../plugins/apm/common/service_nodes'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useAgentName } from '../../../hooks/useAgentName'; -import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; -import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; -import { MetricsChart } from '../../shared/charts/MetricsChart'; -import { useFetcher, FETCH_STATUS } from '../../../hooks/useFetcher'; -import { truncate, px, unit } from '../../../style/variables'; -import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; - -const INITIAL_DATA = { - host: '', - containerId: '' -}; - -const Truncate = styled.span` - display: block; - ${truncate(px(unit * 12))} -`; - -export function ServiceNodeMetrics() { - const { urlParams, uiFilters } = useUrlParams(); - const { serviceName, serviceNodeName } = urlParams; - - const { agentName } = useAgentName(); - const { data } = useServiceMetricCharts(urlParams, agentName); - const { start, end } = urlParams; - - const { data: { host, containerId } = INITIAL_DATA, status } = useFetcher( - callApmApi => { - if (serviceName && serviceNodeName && start && end) { - return callApmApi({ - pathname: - '/api/apm/services/{serviceName}/node/{serviceNodeName}/metadata', - params: { - path: { serviceName, serviceNodeName }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters) - } - } - }); - } - }, - [serviceName, serviceNodeName, start, end, uiFilters] - ); - - const isLoading = status === FETCH_STATUS.LOADING; - - const isAggregatedData = serviceNodeName === SERVICE_NODE_NAME_MISSING; - - return ( -
- - - - -

{serviceName}

-
-
-
-
- - {isAggregatedData ? ( - - - {i18n.translate( - 'xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningDocumentationLink', - { defaultMessage: 'documentation of APM Server' } - )} - - ) - }} - /> - - ) : ( - - - - {serviceName} - - } - /> - - - - {host} - - } - /> - - - - {containerId} - - } - /> - - - )} - - {agentName && serviceNodeName && ( - - - {data.charts.map(chart => ( - - - - - - ))} - - - - )} -
- ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx deleted file mode 100644 index 3af1a70ef3fdc..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx +++ /dev/null @@ -1,187 +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, { useMemo } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiToolTip, - EuiSpacer -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../../../plugins/apm/common/i18n'; -import { SERVICE_NODE_NAME_MISSING } from '../../../../../../../plugins/apm/common/service_nodes'; -import { PROJECTION } from '../../../../../../../plugins/apm/common/projections/typings'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { ManagedTable, ITableColumn } from '../../shared/ManagedTable'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { - asDynamicBytes, - asInteger, - asPercent -} from '../../../utils/formatters'; -import { ServiceNodeMetricOverviewLink } from '../../shared/Links/apm/ServiceNodeMetricOverviewLink'; -import { truncate, px, unit } from '../../../style/variables'; - -const INITIAL_PAGE_SIZE = 25; -const INITIAL_SORT_FIELD = 'cpu'; -const INITIAL_SORT_DIRECTION = 'desc'; - -const ServiceNodeName = styled.div` - ${truncate(px(8 * unit))} -`; - -const ServiceNodeOverview = () => { - const { uiFilters, urlParams } = useUrlParams(); - const { serviceName, start, end } = urlParams; - - const localFiltersConfig: React.ComponentProps = useMemo( - () => ({ - filterNames: ['host', 'containerId', 'podName'], - params: { - serviceName - }, - projection: PROJECTION.SERVICE_NODES - }), - [serviceName] - ); - - const { data: items = [] } = useFetcher( - callApmApi => { - if (!serviceName || !start || !end) { - return undefined; - } - return callApmApi({ - pathname: '/api/apm/services/{serviceName}/serviceNodes', - params: { - path: { - serviceName - }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters) - } - } - }); - }, - [serviceName, start, end, uiFilters] - ); - - if (!serviceName) { - return null; - } - - const columns: Array> = [ - { - name: ( - - <> - {i18n.translate('xpack.apm.jvmsTable.nameColumnLabel', { - defaultMessage: 'Name' - })} - - - ), - field: 'name', - sortable: true, - render: (name: string) => { - const { displayedName, tooltip } = - name === SERVICE_NODE_NAME_MISSING - ? { - displayedName: UNIDENTIFIED_SERVICE_NODES_LABEL, - tooltip: i18n.translate( - 'xpack.apm.jvmsTable.explainServiceNodeNameMissing', - { - defaultMessage: - 'We could not identify which JVMs these metrics belong to. This is likely caused by running a version of APM Server that is older than 7.5. Upgrading to APM Server 7.5 or higher should resolve this issue.' - } - ) - } - : { displayedName: name, tooltip: name }; - - return ( - - - {displayedName} - - - ); - } - }, - { - name: i18n.translate('xpack.apm.jvmsTable.cpuColumnLabel', { - defaultMessage: 'CPU avg' - }), - field: 'cpu', - sortable: true, - render: (value: number | null) => asPercent(value || 0, 1) - }, - { - name: i18n.translate('xpack.apm.jvmsTable.heapMemoryColumnLabel', { - defaultMessage: 'Heap memory avg' - }), - field: 'heapMemory', - sortable: true, - render: asDynamicBytes - }, - { - name: i18n.translate('xpack.apm.jvmsTable.nonHeapMemoryColumnLabel', { - defaultMessage: 'Non-heap memory avg' - }), - field: 'nonHeapMemory', - sortable: true, - render: asDynamicBytes - }, - { - name: i18n.translate('xpack.apm.jvmsTable.threadCountColumnLabel', { - defaultMessage: 'Thread count max' - }), - field: 'threadCount', - sortable: true, - render: asInteger - } - ]; - - return ( - <> - - - - - - - - - - - - - ); -}; - -export { ServiceNodeOverview }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx deleted file mode 100644 index 1ac29c5626e3a..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx +++ /dev/null @@ -1,135 +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 { EuiToolTip } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import styled from 'styled-components'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ServiceListAPIResponse } from '../../../../../../../../plugins/apm/server/lib/services/get_services'; -import { NOT_AVAILABLE_LABEL } from '../../../../../../../../plugins/apm/common/i18n'; -import { fontSizes, truncate } from '../../../../style/variables'; -import { asDecimal, convertTo } from '../../../../utils/formatters'; -import { ManagedTable } from '../../../shared/ManagedTable'; -import { EnvironmentBadge } from '../../../shared/EnvironmentBadge'; -import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink'; - -interface Props { - items: ServiceListAPIResponse['items']; - noItemsMessage?: React.ReactNode; -} - -function formatNumber(value: number) { - if (value === 0) { - return '0'; - } else if (value <= 0.1) { - return '< 0.1'; - } else { - return asDecimal(value); - } -} - -function formatString(value?: string | null) { - return value || NOT_AVAILABLE_LABEL; -} - -const AppLink = styled(TransactionOverviewLink)` - font-size: ${fontSizes.large}; - ${truncate('100%')}; -`; - -export const SERVICE_COLUMNS = [ - { - field: 'serviceName', - name: i18n.translate('xpack.apm.servicesTable.nameColumnLabel', { - defaultMessage: 'Name' - }), - width: '40%', - sortable: true, - render: (serviceName: string) => ( - - {formatString(serviceName)} - - ) - }, - { - field: 'environments', - name: i18n.translate('xpack.apm.servicesTable.environmentColumnLabel', { - defaultMessage: 'Environment' - }), - width: '20%', - sortable: true, - render: (environments: string[]) => ( - - ) - }, - { - field: 'agentName', - name: i18n.translate('xpack.apm.servicesTable.agentColumnLabel', { - defaultMessage: 'Agent' - }), - sortable: true, - render: (agentName: string) => formatString(agentName) - }, - { - field: 'avgResponseTime', - name: i18n.translate('xpack.apm.servicesTable.avgResponseTimeColumnLabel', { - defaultMessage: 'Avg. response time' - }), - sortable: true, - dataType: 'number', - render: (time: number) => - convertTo({ - unit: 'milliseconds', - microseconds: time - }).formatted - }, - { - field: 'transactionsPerMinute', - name: i18n.translate( - 'xpack.apm.servicesTable.transactionsPerMinuteColumnLabel', - { - defaultMessage: 'Trans. per minute' - } - ), - sortable: true, - dataType: 'number', - render: (value: number) => - `${formatNumber(value)} ${i18n.translate( - 'xpack.apm.servicesTable.transactionsPerMinuteUnitLabel', - { - defaultMessage: 'tpm' - } - )}` - }, - { - field: 'errorsPerMinute', - name: i18n.translate('xpack.apm.servicesTable.errorsPerMinuteColumnLabel', { - defaultMessage: 'Errors per minute' - }), - sortable: true, - dataType: 'number', - render: (value: number) => - `${formatNumber(value)} ${i18n.translate( - 'xpack.apm.servicesTable.errorsPerMinuteUnitLabel', - { - defaultMessage: 'err.' - } - )}` - } -]; - -export function ServiceList({ items, noItemsMessage }: Props) { - return ( - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/index.tsx deleted file mode 100644 index 52bc414a93a23..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/index.tsx +++ /dev/null @@ -1,119 +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 { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { EuiLink } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useEffect, useMemo } from 'react'; -import url from 'url'; -import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { NoServicesMessage } from './NoServicesMessage'; -import { ServiceList } from './ServiceList'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useTrackPageview } from '../../../../../../../plugins/observability/public'; -import { PROJECTION } from '../../../../../../../plugins/apm/common/projections/typings'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; -import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; - -const initalData = { - items: [], - hasHistoricalData: true, - hasLegacyData: false -}; - -let hasDisplayedToast = false; - -export function ServiceOverview() { - const { core } = useApmPluginContext(); - const { - urlParams: { start, end }, - uiFilters - } = useUrlParams(); - const { data = initalData, status } = useFetcher( - callApmApi => { - if (start && end) { - return callApmApi({ - pathname: '/api/apm/services', - params: { - query: { start, end, uiFilters: JSON.stringify(uiFilters) } - } - }); - } - }, - [start, end, uiFilters] - ); - - useEffect(() => { - if (data.hasLegacyData && !hasDisplayedToast) { - hasDisplayedToast = true; - - core.notifications.toasts.addWarning({ - title: i18n.translate('xpack.apm.serviceOverview.toastTitle', { - defaultMessage: - 'Legacy data was detected within the selected time range' - }), - text: toMountPoint( -

- {i18n.translate('xpack.apm.serviceOverview.toastText', { - defaultMessage: - "You're running Elastic Stack 7.0+ and we've detected incompatible data from a previous 6.x version. If you want to view this data in APM, you should migrate it. See more in " - })} - - - {i18n.translate( - 'xpack.apm.serviceOverview.upgradeAssistantLink', - { - defaultMessage: 'the upgrade assistant' - } - )} - -

- ) - }); - } - }, [data.hasLegacyData, core.http.basePath, core.notifications.toasts]); - - useTrackPageview({ app: 'apm', path: 'services_overview' }); - useTrackPageview({ app: 'apm', path: 'services_overview', delay: 15000 }); - - const localFiltersConfig: React.ComponentProps = useMemo( - () => ({ - filterNames: ['host', 'agentName'], - projection: PROJECTION.SERVICES - }), - [] - ); - - return ( - <> - - - - - - - - - } - /> - - - - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx deleted file mode 100644 index 638e518563f8c..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx +++ /dev/null @@ -1,157 +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 { isEmpty } from 'lodash'; -import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; -import React, { useState, useEffect, useCallback } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FetcherResult } from '../../../../../hooks/useFetcher'; -import { history } from '../../../../../utils/history'; -import { - AgentConfigurationIntake, - AgentConfiguration -} from '../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; -import { ServicePage } from './ServicePage/ServicePage'; -import { SettingsPage } from './SettingsPage/SettingsPage'; -import { fromQuery, toQuery } from '../../../../shared/Links/url_helpers'; - -type PageStep = 'choose-service-step' | 'choose-settings-step' | 'review-step'; - -function getInitialNewConfig( - existingConfig: AgentConfigurationIntake | undefined -) { - return { - agent_name: existingConfig?.agent_name, - service: existingConfig?.service || {}, - settings: existingConfig?.settings || {} - }; -} - -function setPage(pageStep: PageStep) { - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - pageStep - }) - }); -} - -function getUnsavedChanges({ - newConfig, - existingConfig -}: { - newConfig: AgentConfigurationIntake; - existingConfig?: AgentConfigurationIntake; -}) { - return Object.fromEntries( - Object.entries(newConfig.settings).filter(([key, value]) => { - const existingValue = existingConfig?.settings?.[key]; - - // don't highlight changes that were added and removed - if (value === '' && existingValue == null) { - return false; - } - - return existingValue !== value; - }) - ); -} - -export function AgentConfigurationCreateEdit({ - pageStep, - existingConfigResult -}: { - pageStep: PageStep; - existingConfigResult?: FetcherResult; -}) { - const existingConfig = existingConfigResult?.data; - const isEditMode = Boolean(existingConfigResult); - const [newConfig, setNewConfig] = useState( - getInitialNewConfig(existingConfig) - ); - - const resetSettings = useCallback(() => { - setNewConfig(_newConfig => ({ - ..._newConfig, - settings: existingConfig?.settings || {} - })); - }, [existingConfig]); - - // update newConfig when existingConfig has loaded - useEffect(() => { - setNewConfig(getInitialNewConfig(existingConfig)); - }, [existingConfig]); - - useEffect(() => { - // the user tried to edit the service of an existing config - if (pageStep === 'choose-service-step' && isEditMode) { - setPage('choose-settings-step'); - } - - // the user skipped the first step (select service) - if ( - pageStep === 'choose-settings-step' && - !isEditMode && - isEmpty(newConfig.service) - ) { - setPage('choose-service-step'); - } - }, [isEditMode, newConfig, pageStep]); - - const unsavedChanges = getUnsavedChanges({ newConfig, existingConfig }); - - return ( - <> - -

- {isEditMode - ? i18n.translate('xpack.apm.agentConfig.editConfigTitle', { - defaultMessage: 'Edit configuration' - }) - : i18n.translate('xpack.apm.agentConfig.createConfigTitle', { - defaultMessage: 'Create configuration' - })} -

-
- - - {i18n.translate('xpack.apm.agentConfig.newConfig.description', { - defaultMessage: `This allows you to fine-tune your agent configuration directly in - Kibana. Best of all, changes are automatically propagated to your APM - agents so there’s no need to redeploy.` - })} - - - - - {pageStep === 'choose-service-step' && ( - setPage('choose-settings-step')} - /> - )} - - {pageStep === 'choose-settings-step' && ( - setPage('choose-service-step')} - newConfig={newConfig} - setNewConfig={setNewConfig} - resetSettings={resetSettings} - isEditMode={isEditMode} - /> - )} - - {/* - TODO: Add review step - {pageStep === 'review-step' &&
Review will be here
} - */} - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx deleted file mode 100644 index 6d5f65121d8fd..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx +++ /dev/null @@ -1,221 +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, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiEmptyPrompt, - EuiButton, - EuiButtonEmpty, - EuiHealth, - EuiToolTip, - EuiButtonIcon -} from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { FETCH_STATUS } from '../../../../../hooks/useFetcher'; -import { ITableColumn, ManagedTable } from '../../../../shared/ManagedTable'; -import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AgentConfigurationListAPIResponse } from '../../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; -import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; -import { px, units } from '../../../../../style/variables'; -import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; -import { - createAgentConfigurationHref, - editAgentConfigurationHref -} from '../../../../shared/Links/apm/agentConfigurationLinks'; -import { ConfirmDeleteModal } from './ConfirmDeleteModal'; - -type Config = AgentConfigurationListAPIResponse[0]; - -export function AgentConfigurationList({ - status, - data, - refetch -}: { - status: FETCH_STATUS; - data: Config[]; - refetch: () => void; -}) { - const [configToBeDeleted, setConfigToBeDeleted] = useState( - null - ); - - const emptyStatePrompt = ( - - {i18n.translate( - 'xpack.apm.agentConfig.configTable.emptyPromptTitle', - { defaultMessage: 'No configurations found.' } - )} - - } - body={ - <> -

- {i18n.translate( - 'xpack.apm.agentConfig.configTable.emptyPromptText', - { - defaultMessage: - "Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration." - } - )} -

- - } - actions={ - - {i18n.translate( - 'xpack.apm.agentConfig.configTable.createConfigButtonLabel', - { defaultMessage: 'Create configuration' } - )} - - } - /> - ); - - const failurePrompt = ( - -

- {i18n.translate( - 'xpack.apm.agentConfig.configTable.configTable.failurePromptText', - { - defaultMessage: - 'The list of agent configurations could not be fetched. Your user may not have the sufficient permissions.' - } - )} -

- - } - /> - ); - - if (status === FETCH_STATUS.FAILURE) { - return failurePrompt; - } - - if (status === FETCH_STATUS.SUCCESS && isEmpty(data)) { - return emptyStatePrompt; - } - - const columns: Array> = [ - { - field: 'applied_by_agent', - align: 'center', - width: px(units.double), - name: '', - sortable: true, - render: (isApplied: boolean) => ( - - - - ) - }, - { - field: 'service.name', - name: i18n.translate( - 'xpack.apm.agentConfig.configTable.serviceNameColumnLabel', - { defaultMessage: 'Service name' } - ), - sortable: true, - render: (_, config: Config) => ( - - {getOptionLabel(config.service.name)} - - ) - }, - { - field: 'service.environment', - name: i18n.translate( - 'xpack.apm.agentConfig.configTable.environmentColumnLabel', - { defaultMessage: 'Service environment' } - ), - sortable: true, - render: (environment: string) => getOptionLabel(environment) - }, - { - align: 'right', - field: '@timestamp', - name: i18n.translate( - 'xpack.apm.agentConfig.configTable.lastUpdatedColumnLabel', - { defaultMessage: 'Last updated' } - ), - sortable: true, - render: (value: number) => ( - - ) - }, - { - width: px(units.double), - name: '', - render: (config: Config) => ( - - ) - }, - { - width: px(units.double), - name: '', - render: (config: Config) => ( - setConfigToBeDeleted(config)} - /> - ) - } - ]; - - return ( - <> - {configToBeDeleted && ( - setConfigToBeDeleted(null)} - onConfirm={() => { - setConfigToBeDeleted(null); - refetch(); - }} - /> - )} - - } - columns={columns} - items={data} - initialSortField="service.name" - initialSortDirection="asc" - initialPageSize={20} - /> - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx deleted file mode 100644 index 8171e339adc82..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ /dev/null @@ -1,77 +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 { - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiSpacer, - EuiButton -} from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import { useFetcher } from '../../../../hooks/useFetcher'; -import { AgentConfigurationList } from './List'; -import { useTrackPageview } from '../../../../../../../../plugins/observability/public'; -import { createAgentConfigurationHref } from '../../../shared/Links/apm/agentConfigurationLinks'; - -export function AgentConfigurations() { - const { refetch, data = [], status } = useFetcher( - callApmApi => - callApmApi({ pathname: '/api/apm/settings/agent-configuration' }), - [], - { preservePreviousData: false } - ); - - useTrackPageview({ app: 'apm', path: 'agent_configuration' }); - useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 }); - - const hasConfigurations = !isEmpty(data); - - return ( - <> - - - - -

- {i18n.translate( - 'xpack.apm.agentConfig.configurationsPanelTitle', - { defaultMessage: 'Agent remote configuration' } - )} -

-
-
- - {hasConfigurations ? : null} -
- - - - -
- - ); -} - -function CreateConfigurationButton() { - const href = createAgentConfigurationHref(); - return ( - - - - - {i18n.translate('xpack.apm.agentConfig.createConfigButtonLabel', { - defaultMessage: 'Create configuration' - })} - - - - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx deleted file mode 100644 index 272c4b3add415..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx +++ /dev/null @@ -1,35 +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 { render, wait } from '@testing-library/react'; -import React from 'react'; -import { ApmIndices } from '.'; -import * as hooks from '../../../../hooks/useFetcher'; -import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; - -describe('ApmIndices', () => { - it('should not get stuck in infinite loop', async () => { - spyOn(hooks, 'useFetcher').and.returnValue({ - data: undefined, - status: 'loading' - }); - const { getByText } = render( - - - - ); - - expect(getByText('Indices')).toMatchInlineSnapshot(` -

- Indices -

- `); - - await wait(); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx deleted file mode 100644 index 2f4d9a4c4016d..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx +++ /dev/null @@ -1,61 +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 { LinkPreview } from '../CustomLinkFlyout/LinkPreview'; -import { render, getNodeText, getByTestId, act } from '@testing-library/react'; - -describe('LinkPreview', () => { - const getElementValue = (container: HTMLElement, id: string) => - getNodeText( - ((getByTestId(container, id) as HTMLDivElement) - .children as HTMLCollection)[0] as HTMLDivElement - ); - - it('shows label and url default values', () => { - act(() => { - const { container } = render( - - ); - expect(getElementValue(container, 'preview-label')).toEqual('Elastic.co'); - expect(getElementValue(container, 'preview-url')).toEqual( - 'https://www.elastic.co' - ); - }); - }); - - it('shows label and url values', () => { - act(() => { - const { container } = render( - - ); - expect(getElementValue(container, 'preview-label')).toEqual('foo'); - expect( - (getByTestId(container, 'preview-link') as HTMLAnchorElement).text - ).toEqual('https://baz.co'); - }); - }); - - it('shows warning when couldnt replace context variables', () => { - act(() => { - const { container } = render( - - ); - expect(getElementValue(container, 'preview-label')).toEqual('foo'); - expect( - (getByTestId(container, 'preview-link') as HTMLAnchorElement).text - ).toEqual('https://baz.co?service.name={{invalid}'); - expect(getByTestId(container, 'preview-warning')).toBeInTheDocument(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts deleted file mode 100644 index 0a63cfcff9aa5..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts +++ /dev/null @@ -1,147 +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 { - getSelectOptions, - replaceTemplateVariables -} from '../CustomLinkFlyout/helper'; -import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; - -describe('Custom link helper', () => { - describe('getSelectOptions', () => { - it('returns all available options when no filters were selected', () => { - expect( - getSelectOptions( - [ - { key: '', value: '' }, - { key: '', value: '' }, - { key: '', value: '' }, - { key: '', value: '' } - ], - '' - ) - ).toEqual([ - { value: 'DEFAULT', text: 'Select field...' }, - { value: 'service.name', text: 'service.name' }, - { value: 'service.environment', text: 'service.environment' }, - { value: 'transaction.type', text: 'transaction.type' }, - { value: 'transaction.name', text: 'transaction.name' } - ]); - }); - it('removes item added in another filter', () => { - expect( - getSelectOptions( - [ - { key: 'service.name', value: 'foo' }, - { key: '', value: '' }, - { key: '', value: '' }, - { key: '', value: '' } - ], - '' - ) - ).toEqual([ - { value: 'DEFAULT', text: 'Select field...' }, - { value: 'service.environment', text: 'service.environment' }, - { value: 'transaction.type', text: 'transaction.type' }, - { value: 'transaction.name', text: 'transaction.name' } - ]); - }); - it('removes item added in another filter but keep the current selected', () => { - expect( - getSelectOptions( - [ - { key: 'service.name', value: 'foo' }, - { key: 'transaction.name', value: 'bar' }, - { key: '', value: '' }, - { key: '', value: '' } - ], - 'transaction.name' - ) - ).toEqual([ - { value: 'DEFAULT', text: 'Select field...' }, - { value: 'service.environment', text: 'service.environment' }, - { value: 'transaction.type', text: 'transaction.type' }, - { value: 'transaction.name', text: 'transaction.name' } - ]); - }); - it('returns empty when all option were selected', () => { - expect( - getSelectOptions( - [ - { key: 'service.name', value: 'foo' }, - { key: 'transaction.name', value: 'bar' }, - { key: 'service.environment', value: 'baz' }, - { key: 'transaction.type', value: 'qux' } - ], - '' - ) - ).toEqual([{ value: 'DEFAULT', text: 'Select field...' }]); - }); - }); - - describe('replaceTemplateVariables', () => { - const transaction = ({ - service: { name: 'foo' }, - trace: { id: '123' } - } as unknown) as Transaction; - - it('replaces template variables', () => { - expect( - replaceTemplateVariables( - 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}', - transaction - ) - ).toEqual({ - error: undefined, - formattedUrl: 'https://elastic.co?service.name=foo&trace.id=123' - }); - }); - - it('returns error when transaction is not defined', () => { - const expectedResult = { - error: - "We couldn't find a matching transaction document based on the defined filters.", - formattedUrl: 'https://elastic.co?service.name=&trace.id=' - }; - expect( - replaceTemplateVariables( - 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}' - ) - ).toEqual(expectedResult); - expect( - replaceTemplateVariables( - 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}', - ({} as unknown) as Transaction - ) - ).toEqual(expectedResult); - }); - - it('returns error when could not replace variables', () => { - expect( - replaceTemplateVariables( - 'https://elastic.co?service.name={{service.nam}}&trace.id={{trace.i}}', - transaction - ) - ).toEqual({ - error: - "We couldn't find a value match for {{service.nam}}, {{trace.i}} in the example transaction document.", - formattedUrl: 'https://elastic.co?service.name=&trace.id=' - }); - }); - - it('returns error when variable is invalid', () => { - expect( - replaceTemplateVariables( - 'https://elastic.co?service.name={{service.name}', - transaction - ) - ).toEqual({ - error: - "We couldn't find an example transaction document due to invalid variable(s) defined.", - formattedUrl: 'https://elastic.co?service.name={{service.name}' - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts deleted file mode 100644 index 7bfdbf1655e0d..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import Mustache from 'mustache'; -import { isEmpty, get } from 'lodash'; -import { FILTER_OPTIONS } from '../../../../../../../../../../plugins/apm/common/custom_link/custom_link_filter_options'; -import { - Filter, - FilterKey -} from '../../../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; - -interface FilterSelectOption { - value: 'DEFAULT' | FilterKey; - text: string; -} - -export const DEFAULT_OPTION: FilterSelectOption = { - value: 'DEFAULT', - text: i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.flyOut.filters.defaultOption', - { defaultMessage: 'Select field...' } - ) -}; - -export const FILTER_SELECT_OPTIONS: FilterSelectOption[] = [ - DEFAULT_OPTION, - ...FILTER_OPTIONS.map(filter => ({ - value: filter, - text: filter - })) -]; - -/** - * Returns the options available, removing filters already added, but keeping the selected filter. - * - * @param filters - * @param selectedKey - */ -export const getSelectOptions = ( - filters: Filter[], - selectedKey: Filter['key'] -) => { - return FILTER_SELECT_OPTIONS.filter( - ({ value }) => - !filters.some(({ key }) => key === value && key !== selectedKey) - ); -}; - -const getInvalidTemplateVariables = ( - template: string, - transaction: Transaction -) => { - return (Mustache.parse(template) as Array<[string, string]>) - .filter(([type]) => type === 'name') - .map(([, value]) => value) - .filter(templateVar => get(transaction, templateVar) == null); -}; - -const validateUrl = (url: string, transaction?: Transaction) => { - if (!transaction || isEmpty(transaction)) { - return i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.preview.transaction.notFound', - { - defaultMessage: - "We couldn't find a matching transaction document based on the defined filters." - } - ); - } - try { - const invalidVariables = getInvalidTemplateVariables(url, transaction); - if (!isEmpty(invalidVariables)) { - return i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.preview.contextVariable.noMatch', - { - defaultMessage: - "We couldn't find a value match for {variables} in the example transaction document.", - values: { - variables: invalidVariables - .map(variable => `{{${variable}}}`) - .join(', ') - } - } - ); - } - } catch (e) { - return i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.preview.contextVariable.invalid', - { - defaultMessage: - "We couldn't find an example transaction document due to invalid variable(s) defined." - } - ); - } -}; - -export const replaceTemplateVariables = ( - url: string, - transaction?: Transaction -) => { - const error = validateUrl(url, transaction); - try { - return { formattedUrl: Mustache.render(url, transaction), error }; - } catch (e) { - // errors will be caught on validateUrl function - return { formattedUrl: url, error }; - } -}; - -export const convertFiltersToQuery = (filters: Filter[]) => { - return filters.reduce((acc: Record, { key, value }) => { - if (key && value) { - acc[key] = value; - } - return acc; - }, {}); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx deleted file mode 100644 index 0b25a0a79edd9..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.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 { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiPortal, - EuiSpacer, - EuiText, - EuiTitle -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; -import { Filter } from '../../../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; -import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; -import { FiltersSection } from './FiltersSection'; -import { FlyoutFooter } from './FlyoutFooter'; -import { LinkSection } from './LinkSection'; -import { saveCustomLink } from './saveCustomLink'; -import { LinkPreview } from './LinkPreview'; -import { Documentation } from './Documentation'; - -interface Props { - onClose: () => void; - onSave: () => void; - onDelete: () => void; - defaults?: { - url?: string; - label?: string; - filters?: Filter[]; - }; - customLinkId?: string; -} - -const filtersEmptyState: Filter[] = [{ key: '', value: '' }]; - -export const CustomLinkFlyout = ({ - onClose, - onSave, - onDelete, - defaults, - customLinkId -}: Props) => { - const { toasts } = useApmPluginContext().core.notifications; - const [isSaving, setIsSaving] = useState(false); - - const [label, setLabel] = useState(defaults?.label || ''); - const [url, setUrl] = useState(defaults?.url || ''); - const [filters, setFilters] = useState( - defaults?.filters?.length ? defaults.filters : filtersEmptyState - ); - - const isFormValid = !!label && !!url; - - const onSubmit = async ( - event: - | React.FormEvent - | React.MouseEvent - ) => { - event.preventDefault(); - setIsSaving(true); - await saveCustomLink({ - id: customLinkId, - label, - url, - filters, - toasts - }); - setIsSaving(false); - onSave(); - }; - - return ( - -
- - - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.flyout.title', - { - defaultMessage: 'Create link' - } - )} -

-
-
- - -

- {i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.flyout.label', - { - defaultMessage: - 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. More information, including examples, are available in the' - } - )}{' '} - -

-
- - - - - - - - - - - - -
- - -
-
-
- ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx deleted file mode 100644 index e5c20b260e097..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ /dev/null @@ -1,357 +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 { fireEvent, render, wait, RenderResult } from '@testing-library/react'; -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import * as apmApi from '../../../../../services/rest/createCallApmApi'; -import { License } from '../../../../../../../../../plugins/licensing/common/license'; -import * as hooks from '../../../../../hooks/useFetcher'; -import { LicenseContext } from '../../../../../context/LicenseContext'; -import { CustomLinkOverview } from '.'; -import { - expectTextsInDocument, - expectTextsNotInDocument -} from '../../../../../utils/testHelpers'; -import * as saveCustomLink from './CustomLinkFlyout/saveCustomLink'; -import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; - -const data = [ - { - id: '1', - label: 'label 1', - url: 'url 1', - 'service.name': 'opbeans-java' - }, - { - id: '2', - label: 'label 2', - url: 'url 2', - 'transaction.type': 'request' - } -]; - -describe('CustomLink', () => { - let callApmApiSpy: jasmine.Spy; - beforeAll(() => { - callApmApiSpy = spyOn(apmApi, 'callApmApi').and.returnValue({}); - }); - afterAll(() => { - jest.resetAllMocks(); - }); - const goldLicense = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 0, - mode: 'gold', - status: 'active', - type: 'gold', - uid: '1' - } - }); - describe('empty prompt', () => { - beforeAll(() => { - spyOn(hooks, 'useFetcher').and.returnValue({ - data: [], - status: 'success' - }); - }); - - afterAll(() => { - jest.clearAllMocks(); - }); - it('shows when no link is available', () => { - const component = render( - - - - ); - expectTextsInDocument(component, ['No links found.']); - }); - }); - - describe('overview', () => { - beforeAll(() => { - spyOn(hooks, 'useFetcher').and.returnValue({ - data, - status: 'success' - }); - }); - - afterAll(() => { - jest.clearAllMocks(); - }); - - it('shows a table with all custom link', () => { - const component = render( - - - - - - ); - expectTextsInDocument(component, [ - 'label 1', - 'url 1', - 'label 2', - 'url 2' - ]); - }); - - it('checks if create custom link button is available and working', async () => { - const { queryByText, getByText } = render( - - - - - - ); - expect(queryByText('Create link')).not.toBeInTheDocument(); - act(() => { - fireEvent.click(getByText('Create custom link')); - }); - await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); - expect(queryByText('Create link')).toBeInTheDocument(); - }); - }); - - describe('Flyout', () => { - const refetch = jest.fn(); - let saveCustomLinkSpy: Function; - beforeAll(() => { - saveCustomLinkSpy = spyOn(saveCustomLink, 'saveCustomLink'); - spyOn(hooks, 'useFetcher').and.returnValue({ - data, - status: 'success', - refetch - }); - }); - afterEach(() => { - jest.resetAllMocks(); - }); - - const openFlyout = async () => { - const component = render( - - - - - - ); - expect(component.queryByText('Create link')).not.toBeInTheDocument(); - act(() => { - fireEvent.click(component.getByText('Create custom link')); - }); - await wait(() => - expect(component.queryByText('Create link')).toBeInTheDocument() - ); - await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); - return component; - }; - - it('creates a custom link', async () => { - const component = await openFlyout(); - const labelInput = component.getByTestId('label'); - act(() => { - fireEvent.change(labelInput, { - target: { value: 'foo' } - }); - }); - const urlInput = component.getByTestId('url'); - act(() => { - fireEvent.change(urlInput, { - target: { value: 'bar' } - }); - }); - await act(async () => { - await wait(() => fireEvent.submit(component.getByText('Save'))); - }); - expect(saveCustomLinkSpy).toHaveBeenCalledTimes(1); - }); - - it('deletes a custom link', async () => { - const component = render( - - - - - - ); - expect(component.queryByText('Create link')).not.toBeInTheDocument(); - const editButtons = component.getAllByLabelText('Edit'); - expect(editButtons.length).toEqual(2); - act(() => { - fireEvent.click(editButtons[0]); - }); - expect(component.queryByText('Create link')).toBeInTheDocument(); - await act(async () => { - await wait(() => fireEvent.click(component.getByText('Delete'))); - }); - expect(callApmApiSpy).toHaveBeenCalled(); - expect(refetch).toHaveBeenCalled(); - }); - - describe('Filters', () => { - const addFilterField = (component: RenderResult, amount: number) => { - for (let i = 1; i <= amount; i++) { - fireEvent.click(component.getByText('Add another filter')); - } - }; - it('checks if add filter button is disabled after all elements have been added', async () => { - const component = await openFlyout(); - expect(component.getAllByText('service.name').length).toEqual(1); - addFilterField(component, 1); - expect(component.getAllByText('service.name').length).toEqual(2); - addFilterField(component, 2); - expect(component.getAllByText('service.name').length).toEqual(4); - // After 4 items, the button is disabled - addFilterField(component, 2); - expect(component.getAllByText('service.name').length).toEqual(4); - }); - it('removes items already selected', async () => { - const component = await openFlyout(); - - const addFieldAndCheck = ( - fieldName: string, - selectValue: string, - addNewFilter: boolean, - optionsExpected: string[] - ) => { - if (addNewFilter) { - addFilterField(component, 1); - } - const field = component.getByTestId(fieldName) as HTMLSelectElement; - const optionsAvailable = Object.values(field) - .map(option => (option as HTMLOptionElement).text) - .filter(option => option); - - act(() => { - fireEvent.change(field, { - target: { value: selectValue } - }); - }); - expect(field.value).toEqual(selectValue); - expect(optionsAvailable).toEqual(optionsExpected); - }; - - addFieldAndCheck('filter-0', 'transaction.name', false, [ - 'Select field...', - 'service.name', - 'service.environment', - 'transaction.type', - 'transaction.name' - ]); - - addFieldAndCheck('filter-1', 'service.name', true, [ - 'Select field...', - 'service.name', - 'service.environment', - 'transaction.type' - ]); - - addFieldAndCheck('filter-2', 'transaction.type', true, [ - 'Select field...', - 'service.environment', - 'transaction.type' - ]); - - addFieldAndCheck('filter-3', 'service.environment', true, [ - 'Select field...', - 'service.environment' - ]); - }); - }); - }); - - describe('invalid license', () => { - beforeAll(() => { - spyOn(hooks, 'useFetcher').and.returnValue({ - data: [], - status: 'success' - }); - }); - it('shows license prompt when user has a basic license', () => { - const license = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 0, - mode: 'basic', - status: 'active', - type: 'basic', - uid: '1' - } - }); - const component = render( - - - - - - ); - expectTextsInDocument(component, ['Start free 30-day trial']); - }); - it('shows license prompt when user has an invalid gold license', () => { - const license = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 0, - mode: 'gold', - status: 'invalid', - type: 'gold', - uid: '1' - } - }); - const component = render( - - - - - - ); - expectTextsInDocument(component, ['Start free 30-day trial']); - }); - it('shows license prompt when user has an invalid trial license', () => { - const license = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 0, - mode: 'trial', - status: 'invalid', - type: 'trial', - uid: '1' - } - }); - const component = render( - - - - - - ); - expectTextsInDocument(component, ['Start free 30-day trial']); - }); - it('doesnt show license prompt when user has a trial license', () => { - const license = new License({ - signature: 'test signature', - license: { - expiryDateInMillis: 0, - mode: 'trial', - status: 'active', - type: 'trial', - uid: '1' - } - }); - const component = render( - - - - - - ); - expectTextsNotInDocument(component, ['Start free 30-day trial']); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx deleted file mode 100644 index e9a915e0f59bc..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import React, { useEffect, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { CustomLink } from '../../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; -import { useLicense } from '../../../../../hooks/useLicense'; -import { useFetcher, FETCH_STATUS } from '../../../../../hooks/useFetcher'; -import { CustomLinkFlyout } from './CustomLinkFlyout'; -import { CustomLinkTable } from './CustomLinkTable'; -import { EmptyPrompt } from './EmptyPrompt'; -import { Title } from './Title'; -import { CreateCustomLinkButton } from './CreateCustomLinkButton'; -import { LicensePrompt } from '../../../../shared/LicensePrompt'; - -export const CustomLinkOverview = () => { - const license = useLicense(); - const hasValidLicense = license?.isActive && license?.hasAtLeast('gold'); - - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); - const [customLinkSelected, setCustomLinkSelected] = useState< - CustomLink | undefined - >(); - - const { data: customLinks, status, refetch } = useFetcher( - callApmApi => callApmApi({ pathname: '/api/apm/settings/custom_links' }), - [] - ); - - useEffect(() => { - if (customLinkSelected) { - setIsFlyoutOpen(true); - } - }, [customLinkSelected]); - - const onCloseFlyout = () => { - setCustomLinkSelected(undefined); - setIsFlyoutOpen(false); - }; - - const onCreateCustomLinkClick = () => { - setIsFlyoutOpen(true); - }; - - const showEmptyPrompt = - status === FETCH_STATUS.SUCCESS && isEmpty(customLinks); - - return ( - <> - {isFlyoutOpen && ( - { - onCloseFlyout(); - refetch(); - }} - onDelete={() => { - onCloseFlyout(); - refetch(); - }} - /> - )} - - - - - </EuiFlexItem> - {hasValidLicense && !showEmptyPrompt && ( - <EuiFlexItem> - <EuiFlexGroup alignItems="center" justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <CreateCustomLinkButton onClick={onCreateCustomLinkClick} /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - )} - </EuiFlexGroup> - - <EuiSpacer size="m" /> - {hasValidLicense ? ( - showEmptyPrompt ? ( - <EmptyPrompt onCreateCustomLinkClick={onCreateCustomLinkClick} /> - ) : ( - <CustomLinkTable - items={customLinks} - onCustomLinkSelected={setCustomLinkSelected} - /> - ) - ) : ( - <LicensePrompt - text={i18n.translate( - 'xpack.apm.settings.customizeUI.customLink.license.text', - { - defaultMessage: - "To create custom links, you must be subscribed to an Elastic Gold license or above. With it, you'll have the ability to create custom links to improve your workflow when analyzing your services." - } - )} - /> - )} - </EuiPanel> - </> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TraceLink/index.tsx deleted file mode 100644 index f0301f8917d10..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/index.tsx +++ /dev/null @@ -1,96 +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 { EuiEmptyPrompt } from '@elastic/eui'; -import React from 'react'; -import { Redirect } from 'react-router-dom'; -import styled from 'styled-components'; -import url from 'url'; -import { TRACE_ID } from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; -import { Transaction } from '../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; -import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; -import { useUrlParams } from '../../../hooks/useUrlParams'; - -const CentralizedContainer = styled.div` - height: 100%; - display: flex; -`; - -const redirectToTransactionDetailPage = ({ - transaction, - rangeFrom, - rangeTo -}: { - transaction: Transaction; - rangeFrom?: string; - rangeTo?: string; -}) => - url.format({ - pathname: `/services/${transaction.service.name}/transactions/view`, - query: { - traceId: transaction.trace.id, - transactionId: transaction.transaction.id, - transactionName: transaction.transaction.name, - transactionType: transaction.transaction.type, - rangeFrom, - rangeTo - } - }); - -const redirectToTracePage = ({ - traceId, - rangeFrom, - rangeTo -}: { - traceId: string; - rangeFrom?: string; - rangeTo?: string; -}) => - url.format({ - pathname: `/traces`, - query: { - kuery: encodeURIComponent(`${TRACE_ID} : "${traceId}"`), - rangeFrom, - rangeTo - } - }); - -export const TraceLink = () => { - const { urlParams } = useUrlParams(); - const { traceIdLink: traceId, rangeFrom, rangeTo } = urlParams; - - const { data = { transaction: null }, status } = useFetcher( - callApmApi => { - if (traceId) { - return callApmApi({ - pathname: '/api/apm/transaction/{traceId}', - params: { - path: { - traceId - } - } - }); - } - }, - [traceId] - ); - if (traceId && status === FETCH_STATUS.SUCCESS) { - const to = data.transaction - ? redirectToTransactionDetailPage({ - transaction: data.transaction, - rangeFrom, - rangeTo - }) - : redirectToTracePage({ traceId, rangeFrom, rangeTo }); - return <Redirect to={to} />; - } - - return ( - <CentralizedContainer> - <EuiEmptyPrompt iconType="apmTrace" title={<h2>Fetching trace...</h2>} /> - </CentralizedContainer> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TraceOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TraceOverview/index.tsx deleted file mode 100644 index bfbad78a5c026..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/TraceOverview/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import React, { useMemo } from 'react'; -import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; -import { TraceList } from './TraceList'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useTrackPageview } from '../../../../../../../plugins/observability/public'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; -import { PROJECTION } from '../../../../../../../plugins/apm/common/projections/typings'; - -export function TraceOverview() { - const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; - const { status, data = [] } = useFetcher( - callApmApi => { - if (start && end) { - return callApmApi({ - pathname: '/api/apm/traces', - params: { - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters) - } - } - }); - } - }, - [start, end, uiFilters] - ); - - useTrackPageview({ app: 'apm', path: 'traces_overview' }); - useTrackPageview({ app: 'apm', path: 'traces_overview', delay: 15000 }); - - const localUIFiltersConfig = useMemo(() => { - const config: React.ComponentProps<typeof LocalUIFilters> = { - filterNames: ['transactionResult', 'host', 'containerId', 'podName'], - projection: PROJECTION.TRACES - }; - - return config; - }, []); - - return ( - <> - <EuiSpacer /> - <EuiFlexGroup> - <EuiFlexItem grow={1}> - <LocalUIFilters {...localUIFiltersConfig} showCount={false} /> - </EuiFlexItem> - <EuiFlexItem grow={7}> - <EuiPanel> - <TraceList - items={data} - isLoading={status === FETCH_STATUS.LOADING} - /> - </EuiPanel> - </EuiFlexItem> - </EuiFlexGroup> - </> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx deleted file mode 100644 index e70133aabb679..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ /dev/null @@ -1,212 +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 { EuiIconTip, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import d3 from 'd3'; -import React, { FunctionComponent, useCallback } from 'react'; -import { isEmpty } from 'lodash'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TransactionDistributionAPIResponse } from '../../../../../../../../plugins/apm/server/lib/transactions/distribution'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IBucket } from '../../../../../../../../plugins/apm/server/lib/transactions/distribution/get_buckets/transform'; -import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -import { getDurationFormatter } from '../../../../utils/formatters'; -// @ts-ignore -import Histogram from '../../../shared/charts/Histogram'; -import { EmptyMessage } from '../../../shared/EmptyMessage'; -import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; -import { history } from '../../../../utils/history'; -import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; - -interface IChartPoint { - samples: IBucket['samples']; - x0: number; - x: number; - y: number; - style: { - cursor: string; - }; -} - -export function getFormattedBuckets(buckets: IBucket[], bucketSize: number) { - if (!buckets) { - return []; - } - - return buckets.map( - ({ samples, count, key }): IChartPoint => { - return { - samples, - x0: key, - x: key + bucketSize, - y: count, - style: { - cursor: isEmpty(samples) ? 'default' : 'pointer' - } - }; - } - ); -} - -const getFormatYShort = (transactionType: string | undefined) => ( - t: number -) => { - return i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.unitShortLabel', - { - defaultMessage: - '{transCount} {transType, select, request {req.} other {trans.}}', - values: { - transCount: t, - transType: transactionType - } - } - ); -}; - -const getFormatYLong = (transactionType: string | undefined) => (t: number) => { - return transactionType === 'request' - ? i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel', - { - defaultMessage: - '{transCount, plural, =0 {# request} one {# request} other {# requests}}', - values: { - transCount: t - } - } - ) - : i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel', - { - defaultMessage: - '{transCount, plural, =0 {# transaction} one {# transaction} other {# transactions}}', - values: { - transCount: t - } - } - ); -}; - -interface Props { - distribution?: TransactionDistributionAPIResponse; - urlParams: IUrlParams; - isLoading: boolean; - bucketIndex: number; -} - -export const TransactionDistribution: FunctionComponent<Props> = ( - props: Props -) => { - const { - distribution, - urlParams: { transactionType }, - isLoading, - bucketIndex - } = props; - - const formatYShort = useCallback(getFormatYShort(transactionType), [ - transactionType - ]); - - const formatYLong = useCallback(getFormatYLong(transactionType), [ - transactionType - ]); - - // no data in response - if (!distribution || distribution.noHits) { - // only show loading state if there is no data - else show stale data until new data has loaded - if (isLoading) { - return <LoadingStatePrompt />; - } - - return ( - <EmptyMessage - heading={i18n.translate('xpack.apm.transactionDetails.notFoundLabel', { - defaultMessage: 'No transactions were found.' - })} - /> - ); - } - - const buckets = getFormattedBuckets( - distribution.buckets, - distribution.bucketSize - ); - - const xMax = d3.max(buckets, d => d.x) || 0; - const timeFormatter = getDurationFormatter(xMax); - - return ( - <div> - <EuiTitle size="xs"> - <h5> - {i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChartTitle', - { - defaultMessage: 'Transactions duration distribution' - } - )}{' '} - <EuiIconTip - title={i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingLabel', - { - defaultMessage: 'Sampling' - } - )} - content={i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingDescription', - { - defaultMessage: - "Each bucket will show a sample transaction. If there's no sample available, it's most likely because of the sampling limit set in the agent configuration." - } - )} - position="top" - /> - </h5> - </EuiTitle> - - <Histogram - buckets={buckets} - bucketSize={distribution.bucketSize} - bucketIndex={bucketIndex} - onClick={(bucket: IChartPoint) => { - if (!isEmpty(bucket.samples)) { - const sample = bucket.samples[0]; - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - transactionId: sample.transactionId, - traceId: sample.traceId - }) - }); - } - }} - formatX={(time: number) => timeFormatter(time).formatted} - formatYShort={formatYShort} - formatYLong={formatYLong} - verticalLineHover={(bucket: IChartPoint) => isEmpty(bucket.samples)} - backgroundHover={(bucket: IChartPoint) => !isEmpty(bucket.samples)} - tooltipHeader={(bucket: IChartPoint) => { - const xFormatted = timeFormatter(bucket.x); - const x0Formatted = timeFormatter(bucket.x0); - return `${x0Formatted.value} - ${xFormatted.value} ${xFormatted.unit}`; - }} - tooltipFooter={(bucket: IChartPoint) => - isEmpty(bucket.samples) && - i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.noSampleTooltip', - { - defaultMessage: 'No sample available for this bucket' - } - ) - } - /> - </div> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx deleted file mode 100644 index f57ddb5cf69a2..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx +++ /dev/null @@ -1,239 +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 { - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiHorizontalRule, - EuiPortal, - EuiSpacer, - EuiTabbedContent, - EuiTitle, - EuiBadge, - EuiToolTip -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import styled from 'styled-components'; -import { px, units } from '../../../../../../../style/variables'; -import { Summary } from '../../../../../../shared/Summary'; -import { TimestampTooltip } from '../../../../../../shared/TimestampTooltip'; -import { DurationSummaryItem } from '../../../../../../shared/Summary/DurationSummaryItem'; -import { Span } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; -import { Transaction } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; -import { DiscoverSpanLink } from '../../../../../../shared/Links/DiscoverLinks/DiscoverSpanLink'; -import { Stacktrace } from '../../../../../../shared/Stacktrace'; -import { ResponsiveFlyout } from '../ResponsiveFlyout'; -import { DatabaseContext } from './DatabaseContext'; -import { StickySpanProperties } from './StickySpanProperties'; -import { HttpInfoSummaryItem } from '../../../../../../shared/Summary/HttpInfoSummaryItem'; -import { SpanMetadata } from '../../../../../../shared/MetadataTable/SpanMetadata'; -import { SyncBadge } from '../SyncBadge'; - -function formatType(type: string) { - switch (type) { - case 'db': - return 'DB'; - case 'hard-navigation': - return i18n.translate( - 'xpack.apm.transactionDetails.spanFlyout.spanType.navigationTimingLabel', - { - defaultMessage: 'Navigation timing' - } - ); - default: - return type; - } -} - -function formatSubtype(subtype: string | undefined) { - switch (subtype) { - case 'mysql': - return 'MySQL'; - default: - return subtype; - } -} - -function getSpanTypes(span: Span) { - const { type, subtype, action } = span.span; - - return { - spanType: formatType(type), - spanSubtype: formatSubtype(subtype), - spanAction: action - }; -} - -const SpanBadge = (styled(EuiBadge)` - display: inline-block; - margin-right: ${px(units.quarter)}; -` as unknown) as typeof EuiBadge; - -const HttpInfoContainer = styled('div')` - margin-right: ${px(units.quarter)}; -`; - -interface Props { - span?: Span; - parentTransaction?: Transaction; - totalDuration?: number; - onClose: () => void; -} - -export function SpanFlyout({ - span, - parentTransaction, - totalDuration, - onClose -}: Props) { - if (!span) { - return null; - } - - const stackframes = span.span.stacktrace; - const codeLanguage = parentTransaction?.service.language?.name; - const dbContext = span.span.db; - const httpContext = span.span.http; - const spanTypes = getSpanTypes(span); - const spanHttpStatusCode = httpContext?.response?.status_code; - const spanHttpUrl = httpContext?.url?.original; - const spanHttpMethod = httpContext?.method; - - return ( - <EuiPortal> - <ResponsiveFlyout onClose={onClose} size="m" ownFocus={true}> - <EuiFlyoutHeader hasBorder> - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiTitle> - <h2> - {i18n.translate( - 'xpack.apm.transactionDetails.spanFlyout.spanDetailsTitle', - { - defaultMessage: 'Span details' - } - )} - </h2> - </EuiTitle> - </EuiFlexItem> - - <EuiFlexItem grow={false}> - <DiscoverSpanLink span={span}> - <EuiButtonEmpty iconType="discoverApp"> - {i18n.translate( - 'xpack.apm.transactionDetails.spanFlyout.viewSpanInDiscoverButtonLabel', - { - defaultMessage: 'View span in Discover' - } - )} - </EuiButtonEmpty> - </DiscoverSpanLink> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlyoutHeader> - <EuiFlyoutBody> - <StickySpanProperties span={span} transaction={parentTransaction} /> - <EuiSpacer size="m" /> - <Summary - items={[ - <TimestampTooltip time={span.timestamp.us / 1000} />, - <DurationSummaryItem - duration={span.span.duration.us} - totalDuration={totalDuration} - parentType="transaction" - />, - <> - {spanHttpUrl && ( - <HttpInfoContainer> - <HttpInfoSummaryItem - method={spanHttpMethod} - url={spanHttpUrl} - status={spanHttpStatusCode} - /> - </HttpInfoContainer> - )} - <EuiToolTip - content={i18n.translate( - 'xpack.apm.transactionDetails.spanFlyout.spanType', - { defaultMessage: 'Type' } - )} - > - <SpanBadge color="hollow">{spanTypes.spanType}</SpanBadge> - </EuiToolTip> - {spanTypes.spanSubtype && ( - <EuiToolTip - content={i18n.translate( - 'xpack.apm.transactionDetails.spanFlyout.spanSubtype', - { defaultMessage: 'Subtype' } - )} - > - <SpanBadge color="hollow"> - {spanTypes.spanSubtype} - </SpanBadge> - </EuiToolTip> - )} - {spanTypes.spanAction && ( - <EuiToolTip - content={i18n.translate( - 'xpack.apm.transactionDetails.spanFlyout.spanAction', - { defaultMessage: 'Action' } - )} - > - <SpanBadge color="hollow">{spanTypes.spanAction}</SpanBadge> - </EuiToolTip> - )} - <SyncBadge sync={span.span.sync} /> - </> - ]} - /> - <EuiHorizontalRule /> - <DatabaseContext dbContext={dbContext} /> - <EuiTabbedContent - tabs={[ - { - id: 'stack-trace', - name: i18n.translate( - 'xpack.apm.transactionDetails.spanFlyout.stackTraceTabLabel', - { - defaultMessage: 'Stack Trace' - } - ), - content: ( - <Fragment> - <EuiSpacer size="l" /> - <Stacktrace - stackframes={stackframes} - codeLanguage={codeLanguage} - /> - </Fragment> - ) - }, - { - id: 'metadata', - name: i18n.translate( - 'xpack.apm.propertiesTable.tabs.metadataLabel', - { - defaultMessage: 'Metadata' - } - ), - content: ( - <Fragment> - <EuiSpacer size="m" /> - <SpanMetadata span={span} /> - </Fragment> - ) - } - ]} - /> - </EuiFlyoutBody> - </ResponsiveFlyout> - </EuiPortal> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx deleted file mode 100644 index e24414bb28d52..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx +++ /dev/null @@ -1,97 +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 { - EuiFlexGroup, - EuiFlexItem, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiPortal, - EuiSpacer, - EuiTitle, - EuiHorizontalRule -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { Transaction } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; -import { TransactionActionMenu } from '../../../../../../shared/TransactionActionMenu/TransactionActionMenu'; -import { TransactionSummary } from '../../../../../../shared/Summary/TransactionSummary'; -import { FlyoutTopLevelProperties } from '../FlyoutTopLevelProperties'; -import { ResponsiveFlyout } from '../ResponsiveFlyout'; -import { TransactionMetadata } from '../../../../../../shared/MetadataTable/TransactionMetadata'; -import { DroppedSpansWarning } from './DroppedSpansWarning'; - -interface Props { - onClose: () => void; - transaction?: Transaction; - errorCount?: number; - rootTransactionDuration?: number; -} - -function TransactionPropertiesTable({ - transaction -}: { - transaction: Transaction; -}) { - return ( - <div> - <EuiTitle size="s"> - <h4>Metadata</h4> - </EuiTitle> - <TransactionMetadata transaction={transaction} /> - </div> - ); -} - -export function TransactionFlyout({ - transaction: transactionDoc, - onClose, - errorCount = 0, - rootTransactionDuration -}: Props) { - if (!transactionDoc) { - return null; - } - - return ( - <EuiPortal> - <ResponsiveFlyout onClose={onClose} ownFocus={true} maxWidth={false}> - <EuiFlyoutHeader hasBorder> - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiTitle> - <h4> - {i18n.translate( - 'xpack.apm.transactionDetails.transFlyout.transactionDetailsTitle', - { - defaultMessage: 'Transaction details' - } - )} - </h4> - </EuiTitle> - </EuiFlexItem> - - <EuiFlexItem grow={false}> - <TransactionActionMenu transaction={transactionDoc} /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlyoutHeader> - <EuiFlyoutBody> - <FlyoutTopLevelProperties transaction={transactionDoc} /> - <EuiSpacer size="m" /> - <TransactionSummary - transaction={transactionDoc} - totalDuration={rootTransactionDuration} - errorCount={errorCount} - /> - <EuiHorizontalRule margin="m" /> - <DroppedSpansWarning transactionDoc={transactionDoc} /> - <TransactionPropertiesTable transaction={transactionDoc} /> - </EuiFlyoutBody> - </ResponsiveFlyout> - </EuiPortal> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx deleted file mode 100644 index 1082052c6929d..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx +++ /dev/null @@ -1,138 +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 { - EuiEmptyPrompt, - EuiFlexGroup, - EuiFlexItem, - EuiPagination, - EuiPanel, - EuiSpacer, - EuiTitle -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; -import React, { useEffect, useState } from 'react'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IBucket } from '../../../../../../../../plugins/apm/server/lib/transactions/distribution/get_buckets/transform'; -import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -import { history } from '../../../../utils/history'; -import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; -import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; -import { TransactionSummary } from '../../../shared/Summary/TransactionSummary'; -import { TransactionActionMenu } from '../../../shared/TransactionActionMenu/TransactionActionMenu'; -import { MaybeViewTraceLink } from './MaybeViewTraceLink'; -import { TransactionTabs } from './TransactionTabs'; -import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers'; - -interface Props { - urlParams: IUrlParams; - location: Location; - waterfall: IWaterfall; - exceedsMax: boolean; - isLoading: boolean; - traceSamples: IBucket['samples']; -} - -export const WaterfallWithSummmary: React.FC<Props> = ({ - urlParams, - location, - waterfall, - exceedsMax, - isLoading, - traceSamples -}) => { - const [sampleActivePage, setSampleActivePage] = useState(0); - - useEffect(() => { - setSampleActivePage(0); - }, [traceSamples]); - - const goToSample = (index: number) => { - setSampleActivePage(index); - const sample = traceSamples[index]; - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - transactionId: sample.transactionId, - traceId: sample.traceId - }) - }); - }; - - const { entryTransaction } = waterfall; - if (!entryTransaction) { - const content = isLoading ? ( - <LoadingStatePrompt /> - ) : ( - <EuiEmptyPrompt - title={ - <div> - {i18n.translate('xpack.apm.transactionDetails.traceNotFound', { - defaultMessage: 'The selected trace cannot be found' - })} - </div> - } - titleSize="s" - /> - ); - - return <EuiPanel paddingSize="m">{content}</EuiPanel>; - } - - return ( - <EuiPanel paddingSize="m"> - <EuiFlexGroup> - <EuiFlexItem style={{ flexDirection: 'row', alignItems: 'center' }}> - <EuiTitle size="xs"> - <h5> - {i18n.translate('xpack.apm.transactionDetails.traceSampleTitle', { - defaultMessage: 'Trace sample' - })} - </h5> - </EuiTitle> - {traceSamples && ( - <EuiPagination - pageCount={traceSamples.length} - activePage={sampleActivePage} - onPageClick={goToSample} - compressed - /> - )} - </EuiFlexItem> - <EuiFlexItem> - <EuiFlexGroup justifyContent="flexEnd"> - <EuiFlexItem grow={false}> - <TransactionActionMenu transaction={entryTransaction} /> - </EuiFlexItem> - <MaybeViewTraceLink - transaction={entryTransaction} - waterfall={waterfall} - /> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - - <EuiSpacer size="s" /> - - <TransactionSummary - errorCount={waterfall.errorsCount} - totalDuration={waterfall.rootTransaction?.transaction.duration.us} - transaction={entryTransaction} - /> - <EuiSpacer size="s" /> - - <TransactionTabs - transaction={entryTransaction} - location={location} - urlParams={urlParams} - waterfall={waterfall} - exceedsMax={exceedsMax} - /> - </EuiPanel> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/index.tsx deleted file mode 100644 index e2634be0e0be8..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ /dev/null @@ -1,127 +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 { - EuiPanel, - EuiSpacer, - EuiTitle, - EuiHorizontalRule, - EuiFlexGroup, - EuiFlexItem -} from '@elastic/eui'; -import _ from 'lodash'; -import React, { useMemo } from 'react'; -import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; -import { useTransactionDistribution } from '../../../hooks/useTransactionDistribution'; -import { useWaterfall } from '../../../hooks/useWaterfall'; -import { TransactionCharts } from '../../shared/charts/TransactionCharts'; -import { ApmHeader } from '../../shared/ApmHeader'; -import { TransactionDistribution } from './Distribution'; -import { WaterfallWithSummmary } from './WaterfallWithSummmary'; -import { useLocation } from '../../../hooks/useLocation'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { FETCH_STATUS } from '../../../hooks/useFetcher'; -import { TransactionBreakdown } from '../../shared/TransactionBreakdown'; -import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; -import { useTrackPageview } from '../../../../../../../plugins/observability/public'; -import { PROJECTION } from '../../../../../../../plugins/apm/common/projections/typings'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; -import { HeightRetainer } from '../../shared/HeightRetainer'; - -export function TransactionDetails() { - const location = useLocation(); - const { urlParams } = useUrlParams(); - const { - data: distributionData, - status: distributionStatus - } = useTransactionDistribution(urlParams); - - const { data: transactionChartsData } = useTransactionCharts(); - const { waterfall, exceedsMax, status: waterfallStatus } = useWaterfall( - urlParams - ); - const { transactionName, transactionType, serviceName } = urlParams; - - useTrackPageview({ app: 'apm', path: 'transaction_details' }); - useTrackPageview({ app: 'apm', path: 'transaction_details', delay: 15000 }); - - const localUIFiltersConfig = useMemo(() => { - const config: React.ComponentProps<typeof LocalUIFilters> = { - filterNames: ['transactionResult', 'serviceVersion'], - projection: PROJECTION.TRANSACTIONS, - params: { - transactionName, - transactionType, - serviceName - } - }; - return config; - }, [transactionName, transactionType, serviceName]); - - const bucketIndex = distributionData.buckets.findIndex(bucket => - bucket.samples.some( - sample => - sample.transactionId === urlParams.transactionId && - sample.traceId === urlParams.traceId - ) - ); - - const traceSamples = distributionData.buckets[bucketIndex]?.samples; - - return ( - <div> - <ApmHeader> - <EuiTitle size="l"> - <h1>{transactionName}</h1> - </EuiTitle> - </ApmHeader> - - <EuiFlexGroup> - <EuiFlexItem grow={1}> - <LocalUIFilters {...localUIFiltersConfig} /> - </EuiFlexItem> - <EuiFlexItem grow={7}> - <ChartsSyncContextProvider> - <TransactionBreakdown /> - - <EuiSpacer size="s" /> - - <TransactionCharts - hasMLJob={false} - charts={transactionChartsData} - urlParams={urlParams} - location={location} - /> - </ChartsSyncContextProvider> - - <EuiHorizontalRule size="full" margin="l" /> - - <EuiPanel> - <TransactionDistribution - distribution={distributionData} - isLoading={distributionStatus === FETCH_STATUS.LOADING} - urlParams={urlParams} - bucketIndex={bucketIndex} - /> - </EuiPanel> - - <EuiSpacer size="s" /> - - <HeightRetainer> - <WaterfallWithSummmary - location={location} - urlParams={urlParams} - waterfall={waterfall} - isLoading={waterfallStatus === FETCH_STATUS.LOADING} - exceedsMax={exceedsMax} - traceSamples={traceSamples} - /> - </HeightRetainer> - </EuiFlexItem> - </EuiFlexGroup> - </div> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/List/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/List/index.tsx deleted file mode 100644 index 16fda7c600906..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/List/index.tsx +++ /dev/null @@ -1,160 +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 { EuiIcon, EuiToolTip } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; -import styled from 'styled-components'; -import { NOT_AVAILABLE_LABEL } from '../../../../../../../../plugins/apm/common/i18n'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ITransactionGroup } from '../../../../../../../../plugins/apm/server/lib/transaction_groups/transform'; -import { fontFamilyCode, truncate } from '../../../../style/variables'; -import { asDecimal, convertTo } from '../../../../utils/formatters'; -import { ImpactBar } from '../../../shared/ImpactBar'; -import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable'; -import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; -import { EmptyMessage } from '../../../shared/EmptyMessage'; -import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink'; - -const TransactionNameLink = styled(TransactionDetailLink)` - ${truncate('100%')}; - font-family: ${fontFamilyCode}; -`; - -interface Props { - items: ITransactionGroup[]; - isLoading: boolean; -} - -const toMilliseconds = (time: number) => - convertTo({ - unit: 'milliseconds', - microseconds: time - }).formatted; - -export function TransactionList({ items, isLoading }: Props) { - const columns: Array<ITableColumn<ITransactionGroup>> = useMemo( - () => [ - { - field: 'name', - name: i18n.translate('xpack.apm.transactionsTable.nameColumnLabel', { - defaultMessage: 'Name' - }), - width: '50%', - sortable: true, - render: (transactionName: string, { sample }: ITransactionGroup) => { - return ( - <EuiToolTip - id="transaction-name-link-tooltip" - content={transactionName || NOT_AVAILABLE_LABEL} - > - <TransactionNameLink - serviceName={sample.service.name} - transactionId={sample.transaction.id} - traceId={sample.trace.id} - transactionName={sample.transaction.name} - transactionType={sample.transaction.type} - > - {transactionName || NOT_AVAILABLE_LABEL} - </TransactionNameLink> - </EuiToolTip> - ); - } - }, - { - field: 'averageResponseTime', - name: i18n.translate( - 'xpack.apm.transactionsTable.avgDurationColumnLabel', - { - defaultMessage: 'Avg. duration' - } - ), - sortable: true, - dataType: 'number', - render: (time: number) => toMilliseconds(time) - }, - { - field: 'p95', - name: i18n.translate( - 'xpack.apm.transactionsTable.95thPercentileColumnLabel', - { - defaultMessage: '95th percentile' - } - ), - sortable: true, - dataType: 'number', - render: (time: number) => toMilliseconds(time) - }, - { - field: 'transactionsPerMinute', - name: i18n.translate( - 'xpack.apm.transactionsTable.transactionsPerMinuteColumnLabel', - { - defaultMessage: 'Trans. per minute' - } - ), - sortable: true, - dataType: 'number', - render: (value: number) => - `${asDecimal(value)} ${i18n.translate( - 'xpack.apm.transactionsTable.transactionsPerMinuteUnitLabel', - { - defaultMessage: 'tpm' - } - )}` - }, - { - field: 'impact', - name: ( - <EuiToolTip - content={i18n.translate( - 'xpack.apm.transactionsTable.impactColumnDescription', - { - defaultMessage: - "The most used and slowest endpoints in your service. It's calculated by taking the relative average duration times the number of transactions per minute." - } - )} - > - <> - {i18n.translate('xpack.apm.transactionsTable.impactColumnLabel', { - defaultMessage: 'Impact' - })}{' '} - <EuiIcon - size="s" - color="subdued" - type="questionInCircle" - className="eui-alignTop" - /> - </> - </EuiToolTip> - ), - sortable: true, - dataType: 'number', - render: (value: number) => <ImpactBar value={value} /> - } - ], - [] - ); - - const noItemsMessage = ( - <EmptyMessage - heading={i18n.translate('xpack.apm.transactionsTable.notFoundLabel', { - defaultMessage: 'No transactions were found.' - })} - /> - ); - - return ( - <ManagedTable - noItemsMessage={isLoading ? <LoadingStatePrompt /> : noItemsMessage} - columns={columns} - items={items} - initialSortField="impact" - initialSortDirection="desc" - initialPageSize={25} - /> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/index.tsx deleted file mode 100644 index b008c98417867..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ /dev/null @@ -1,162 +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 { - EuiPanel, - EuiSpacer, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule -} from '@elastic/eui'; -import { Location } from 'history'; -import { first } from 'lodash'; -import React, { useMemo } from 'react'; -import { useTransactionList } from '../../../hooks/useTransactionList'; -import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; -import { IUrlParams } from '../../../context/UrlParamsContext/types'; -import { TransactionCharts } from '../../shared/charts/TransactionCharts'; -import { TransactionBreakdown } from '../../shared/TransactionBreakdown'; -import { TransactionList } from './List'; -import { useRedirect } from './useRedirect'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { getHasMLJob } from '../../../services/rest/ml'; -import { history } from '../../../utils/history'; -import { useLocation } from '../../../hooks/useLocation'; -import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; -import { useTrackPageview } from '../../../../../../../plugins/observability/public'; -import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; -import { LocalUIFilters } from '../../shared/LocalUIFilters'; -import { PROJECTION } from '../../../../../../../plugins/apm/common/projections/typings'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; -import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter'; -import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; - -function getRedirectLocation({ - urlParams, - location, - serviceTransactionTypes -}: { - location: Location; - urlParams: IUrlParams; - serviceTransactionTypes: string[]; -}): Location | undefined { - const { transactionType } = urlParams; - const firstTransactionType = first(serviceTransactionTypes); - - if (!transactionType && firstTransactionType) { - return { - ...location, - search: fromQuery({ - ...toQuery(location.search), - transactionType: firstTransactionType - }) - }; - } -} - -export function TransactionOverview() { - const location = useLocation(); - const { urlParams } = useUrlParams(); - const { serviceName, transactionType } = urlParams; - - // TODO: fetching of transaction types should perhaps be lifted since it is needed in several places. Context? - const serviceTransactionTypes = useServiceTransactionTypes(urlParams); - - // redirect to first transaction type - useRedirect( - history, - getRedirectLocation({ - urlParams, - location, - serviceTransactionTypes - }) - ); - - const { data: transactionCharts } = useTransactionCharts(); - - useTrackPageview({ app: 'apm', path: 'transaction_overview' }); - useTrackPageview({ app: 'apm', path: 'transaction_overview', delay: 15000 }); - const { - data: transactionListData, - status: transactionListStatus - } = useTransactionList(urlParams); - - const { http } = useApmPluginContext().core; - - const { data: hasMLJob = false } = useFetcher(() => { - if (serviceName && transactionType) { - return getHasMLJob({ serviceName, transactionType, http }); - } - }, [http, serviceName, transactionType]); - - const localFiltersConfig: React.ComponentProps<typeof LocalUIFilters> = useMemo( - () => ({ - filterNames: [ - 'transactionResult', - 'host', - 'containerId', - 'podName', - 'serviceVersion' - ], - params: { - serviceName, - transactionType - }, - projection: PROJECTION.TRANSACTION_GROUPS - }), - [serviceName, transactionType] - ); - - // TODO: improve urlParams typings. - // `serviceName` or `transactionType` will never be undefined here, and this check should not be needed - if (!serviceName || !transactionType) { - return null; - } - - return ( - <> - <EuiSpacer /> - <EuiFlexGroup> - <EuiFlexItem grow={1}> - <LocalUIFilters {...localFiltersConfig}> - <TransactionTypeFilter transactionTypes={serviceTransactionTypes} /> - <EuiSpacer size="xl" /> - <EuiHorizontalRule margin="none" /> - </LocalUIFilters> - </EuiFlexItem> - <EuiFlexItem grow={7}> - <ChartsSyncContextProvider> - <TransactionBreakdown initialIsOpen={true} /> - - <EuiSpacer size="s" /> - - <TransactionCharts - hasMLJob={hasMLJob} - charts={transactionCharts} - location={location} - urlParams={urlParams} - /> - </ChartsSyncContextProvider> - - <EuiSpacer size="s" /> - - <EuiPanel> - <EuiTitle size="xs"> - <h3>Transactions</h3> - </EuiTitle> - <EuiSpacer size="s" /> - <TransactionList - isLoading={transactionListStatus === 'loading'} - items={transactionListData} - /> - </EuiPanel> - </EuiFlexItem> - </EuiFlexGroup> - </> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx deleted file mode 100644 index e911011f0979c..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx +++ /dev/null @@ -1,112 +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 { EuiSelect } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { useFetcher } from '../../../hooks/useFetcher'; -import { useLocation } from '../../../hooks/useLocation'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { history } from '../../../utils/history'; -import { fromQuery, toQuery } from '../Links/url_helpers'; -import { - ENVIRONMENT_ALL, - ENVIRONMENT_NOT_DEFINED -} from '../../../../../../../plugins/apm/common/environment_filter_values'; - -function updateEnvironmentUrl( - location: ReturnType<typeof useLocation>, - environment?: string -) { - const nextEnvironmentQueryParam = - environment !== ENVIRONMENT_ALL ? environment : undefined; - history.push({ - ...location, - search: fromQuery({ - ...toQuery(location.search), - environment: nextEnvironmentQueryParam - }) - }); -} - -const ALL_OPTION = { - value: ENVIRONMENT_ALL, - text: i18n.translate('xpack.apm.filter.environment.allLabel', { - defaultMessage: 'All' - }) -}; - -const NOT_DEFINED_OPTION = { - value: ENVIRONMENT_NOT_DEFINED, - text: i18n.translate('xpack.apm.filter.environment.notDefinedLabel', { - defaultMessage: 'Not defined' - }) -}; - -const SEPARATOR_OPTION = { - text: `- ${i18n.translate( - 'xpack.apm.filter.environment.selectEnvironmentLabel', - { defaultMessage: 'Select environment' } - )} -`, - disabled: true -}; - -function getOptions(environments: string[]) { - const environmentOptions = environments - .filter(env => env !== ENVIRONMENT_NOT_DEFINED) - .map(environment => ({ - value: environment, - text: environment - })); - - return [ - ALL_OPTION, - ...(environments.includes(ENVIRONMENT_NOT_DEFINED) - ? [NOT_DEFINED_OPTION] - : []), - ...(environmentOptions.length > 0 ? [SEPARATOR_OPTION] : []), - ...environmentOptions - ]; -} - -export const EnvironmentFilter: React.FC = () => { - const location = useLocation(); - const { urlParams, uiFilters } = useUrlParams(); - const { start, end, serviceName } = urlParams; - - const { environment } = uiFilters; - const { data: environments = [], status = 'loading' } = useFetcher( - callApmApi => { - if (start && end) { - return callApmApi({ - pathname: '/api/apm/ui_filters/environments', - params: { - query: { - start, - end, - serviceName - } - } - }); - } - }, - [start, end, serviceName] - ); - - return ( - <EuiSelect - prepend={i18n.translate('xpack.apm.filter.environment.label', { - defaultMessage: 'environment' - })} - options={getOptions(environments)} - value={environment || ENVIRONMENT_ALL} - onChange={event => { - updateEnvironmentUrl(location, event.target.value); - }} - isLoading={status === 'loading'} - /> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx deleted file mode 100644 index b7e23c2979cb8..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { EuiFieldNumber } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { isFinite } from 'lodash'; -import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; -import { ALERT_TYPES_CONFIG } from '../../../../../../../plugins/apm/common/alert_types'; -import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; -import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; - -export interface ErrorRateAlertTriggerParams { - windowSize: number; - windowUnit: string; - threshold: number; -} - -interface Props { - alertParams: ErrorRateAlertTriggerParams; - setAlertParams: (key: string, value: any) => void; - setAlertProperty: (key: string, value: any) => void; -} - -export function ErrorRateAlertTrigger(props: Props) { - const { setAlertParams, setAlertProperty, alertParams } = props; - - const defaults = { - threshold: 25, - windowSize: 1, - windowUnit: 'm' - }; - - const params = { - ...defaults, - ...alertParams - }; - - const threshold = isFinite(params.threshold) ? params.threshold : ''; - - const fields = [ - <PopoverExpression - title={i18n.translate('xpack.apm.errorRateAlertTrigger.isAbove', { - defaultMessage: 'is above' - })} - value={threshold.toString()} - > - <EuiFieldNumber - value={threshold} - step={0} - onChange={e => - setAlertParams('threshold', parseInt(e.target.value, 10)) - } - compressed - append={i18n.translate('xpack.apm.errorRateAlertTrigger.errors', { - defaultMessage: 'errors' - })} - /> - </PopoverExpression>, - <ForLastExpression - onChangeWindowSize={windowSize => - setAlertParams('windowSize', windowSize || '') - } - onChangeWindowUnit={windowUnit => - setAlertParams('windowUnit', windowUnit) - } - timeWindowSize={params.windowSize} - timeWindowUnit={params.windowUnit} - errors={{ - timeWindowSize: [], - timeWindowUnit: [] - }} - /> - ]; - - return ( - <ServiceAlertTrigger - alertTypeName={ALERT_TYPES_CONFIG['apm.error_rate'].name} - defaults={defaults} - fields={fields} - setAlertParams={setAlertParams} - setAlertProperty={setAlertProperty} - /> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/index.tsx deleted file mode 100644 index dba31822dd23e..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/index.tsx +++ /dev/null @@ -1,156 +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, { useState } from 'react'; -import { uniqueId, startsWith } from 'lodash'; -import styled from 'styled-components'; -import { i18n } from '@kbn/i18n'; -import { fromQuery, toQuery } from '../Links/url_helpers'; -// @ts-ignore -import { Typeahead } from './Typeahead'; -import { getBoolFilter } from './get_bool_filter'; -import { useLocation } from '../../../hooks/useLocation'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { history } from '../../../utils/history'; -import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; -import { useDynamicIndexPattern } from '../../../hooks/useDynamicIndexPattern'; -import { - QuerySuggestion, - esKuery, - IIndexPattern -} from '../../../../../../../../src/plugins/data/public'; - -const Container = styled.div` - margin-bottom: 10px; -`; - -interface State { - suggestions: QuerySuggestion[]; - isLoadingSuggestions: boolean; -} - -function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) { - const ast = esKuery.fromKueryExpression(kuery); - return esKuery.toElasticsearchQuery(ast, indexPattern); -} - -export function KueryBar() { - const [state, setState] = useState<State>({ - suggestions: [], - isLoadingSuggestions: false - }); - const { urlParams } = useUrlParams(); - const location = useLocation(); - const { data } = useApmPluginContext().plugins; - - let currentRequestCheck; - - const { processorEvent } = urlParams; - - const examples = { - transaction: 'transaction.duration.us > 300000', - error: 'http.response.status_code >= 400', - metric: 'process.pid = "1234"', - defaults: - 'transaction.duration.us > 300000 AND http.response.status_code >= 400' - }; - - const example = examples[processorEvent || 'defaults']; - - const { indexPattern } = useDynamicIndexPattern(processorEvent); - - const placeholder = i18n.translate('xpack.apm.kueryBar.placeholder', { - defaultMessage: `Search {event, select, - transaction {transactions} - metric {metrics} - error {errors} - other {transactions, errors and metrics} - } (E.g. {queryExample})`, - values: { - queryExample: example, - event: processorEvent - } - }); - - // The bar should be disabled when viewing the service map - const disabled = /\/service-map$/.test(location.pathname); - const disabledPlaceholder = i18n.translate( - 'xpack.apm.kueryBar.disabledPlaceholder', - { defaultMessage: 'Search is not available for service map' } - ); - - async function onChange(inputValue: string, selectionStart: number) { - if (indexPattern == null) { - return; - } - - setState({ ...state, suggestions: [], isLoadingSuggestions: true }); - - const currentRequest = uniqueId(); - currentRequestCheck = currentRequest; - - try { - const suggestions = ( - (await data.autocomplete.getQuerySuggestions({ - language: 'kuery', - indexPatterns: [indexPattern], - boolFilter: getBoolFilter(urlParams), - query: inputValue, - selectionStart, - selectionEnd: selectionStart - })) || [] - ) - .filter(suggestion => !startsWith(suggestion.text, 'span.')) - .slice(0, 15); - - if (currentRequest !== currentRequestCheck) { - return; - } - - setState({ ...state, suggestions, isLoadingSuggestions: false }); - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error while fetching suggestions', e); - } - } - - function onSubmit(inputValue: string) { - if (indexPattern == null) { - return; - } - - try { - const res = convertKueryToEsQuery(inputValue, indexPattern); - if (!res) { - return; - } - - history.push({ - ...location, - search: fromQuery({ - ...toQuery(location.search), - kuery: encodeURIComponent(inputValue.trim()) - }) - }); - } catch (e) { - console.log('Invalid kuery syntax'); // eslint-disable-line no-console - } - } - - return ( - <Container> - <Typeahead - disabled={disabled} - isLoading={state.isLoadingSuggestions} - initialValue={urlParams.kuery} - onChange={onChange} - onSubmit={onSubmit} - suggestions={state.suggestions} - placeholder={disabled ? disabledPlaceholder : placeholder} - /> - </Container> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/index.tsx deleted file mode 100644 index cede3e394cfab..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/index.tsx +++ /dev/null @@ -1,95 +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 { - EuiTitle, - EuiSpacer, - EuiHorizontalRule, - EuiButtonEmpty -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { LocalUIFilterName } from '../../../../../../../plugins/apm/server/lib/ui_filters/local_ui_filters/config'; -import { Filter } from './Filter'; -import { useLocalUIFilters } from '../../../hooks/useLocalUIFilters'; -import { PROJECTION } from '../../../../../../../plugins/apm/common/projections/typings'; - -interface Props { - projection: PROJECTION; - filterNames: LocalUIFilterName[]; - params?: Record<string, string | number | boolean | undefined>; - showCount?: boolean; - children?: React.ReactNode; -} - -const ButtonWrapper = styled.div` - display: inline-block; -`; - -const LocalUIFilters = ({ - projection, - params, - filterNames, - children, - showCount = true -}: Props) => { - const { filters, setFilterValue, clearValues } = useLocalUIFilters({ - filterNames, - projection, - params - }); - - const hasValues = filters.some(filter => filter.value.length > 0); - - return ( - <> - <EuiTitle size="s"> - <h3> - {i18n.translate('xpack.apm.localFiltersTitle', { - defaultMessage: 'Filters' - })} - </h3> - </EuiTitle> - <EuiSpacer size="s" /> - {children} - {filters.map(filter => { - return ( - <React.Fragment key={filter.name}> - <Filter - {...filter} - onChange={value => { - setFilterValue(filter.name, value); - }} - showCount={showCount} - /> - <EuiHorizontalRule margin="none" /> - </React.Fragment> - ); - })} - {hasValues ? ( - <> - <EuiSpacer size="s" /> - <ButtonWrapper> - <EuiButtonEmpty - size="xs" - iconType="cross" - flush="left" - onClick={clearValues} - > - {i18n.translate('xpack.apm.clearFilters', { - defaultMessage: 'Clear filters' - })} - </EuiButtonEmpty> - </ButtonWrapper> - </> - ) : null} - </> - ); -}; - -export { LocalUIFilters }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.tsx deleted file mode 100644 index ce991d8b0dc00..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.tsx +++ /dev/null @@ -1,23 +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, { useMemo } from 'react'; -import { ERROR_METADATA_SECTIONS } from './sections'; -import { APMError } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; -import { getSectionsWithRows } from '../helper'; -import { MetadataTable } from '..'; - -interface Props { - error: APMError; -} - -export function ErrorMetadata({ error }: Props) { - const sectionsWithRows = useMemo( - () => getSectionsWithRows(ERROR_METADATA_SECTIONS, error), - [error] - ); - return <MetadataTable sections={sectionsWithRows} />; -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.tsx deleted file mode 100644 index 2134f12531a7a..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.tsx +++ /dev/null @@ -1,23 +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, { useMemo } from 'react'; -import { SPAN_METADATA_SECTIONS } from './sections'; -import { Span } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; -import { getSectionsWithRows } from '../helper'; -import { MetadataTable } from '..'; - -interface Props { - span: Span; -} - -export function SpanMetadata({ span }: Props) { - const sectionsWithRows = useMemo( - () => getSectionsWithRows(SPAN_METADATA_SECTIONS, span), - [span] - ); - return <MetadataTable sections={sectionsWithRows} />; -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.tsx deleted file mode 100644 index 6f93de4e87e49..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.tsx +++ /dev/null @@ -1,23 +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, { useMemo } from 'react'; -import { TRANSACTION_METADATA_SECTIONS } from './sections'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; -import { getSectionsWithRows } from '../helper'; -import { MetadataTable } from '..'; - -interface Props { - transaction: Transaction; -} - -export function TransactionMetadata({ transaction }: Props) { - const sectionsWithRows = useMemo( - () => getSectionsWithRows(TRANSACTION_METADATA_SECTIONS, transaction), - [transaction] - ); - return <MetadataTable sections={sectionsWithRows} />; -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/helper.test.ts b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/helper.test.ts deleted file mode 100644 index b65b52bf30a5c..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/helper.test.ts +++ /dev/null @@ -1,85 +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 { getSectionsWithRows, filterSectionsByTerm } from '../helper'; -import { LABELS, HTTP, SERVICE } from '../sections'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; - -describe('MetadataTable Helper', () => { - const sections = [ - { ...LABELS, required: true }, - HTTP, - { ...SERVICE, properties: ['environment'] } - ]; - const apmDoc = ({ - http: { - headers: { - Connection: 'close', - Host: 'opbeans:3000', - request: { method: 'get' } - } - }, - service: { - framework: { name: 'express' }, - environment: 'production' - } - } as unknown) as Transaction; - const metadataItems = getSectionsWithRows(sections, apmDoc); - - it('returns flattened data and required section', () => { - expect(metadataItems).toEqual([ - { key: 'labels', label: 'Labels', required: true, rows: [] }, - { - key: 'http', - label: 'HTTP', - rows: [ - { key: 'http.headers.Connection', value: 'close' }, - { key: 'http.headers.Host', value: 'opbeans:3000' }, - { key: 'http.headers.request.method', value: 'get' } - ] - }, - { - key: 'service', - label: 'Service', - properties: ['environment'], - rows: [{ key: 'service.environment', value: 'production' }] - } - ]); - }); - describe('filter', () => { - it('items by key', () => { - const filteredItems = filterSectionsByTerm(metadataItems, 'http'); - expect(filteredItems).toEqual([ - { - key: 'http', - label: 'HTTP', - rows: [ - { key: 'http.headers.Connection', value: 'close' }, - { key: 'http.headers.Host', value: 'opbeans:3000' }, - { key: 'http.headers.request.method', value: 'get' } - ] - } - ]); - }); - - it('items by value', () => { - const filteredItems = filterSectionsByTerm(metadataItems, 'product'); - expect(filteredItems).toEqual([ - { - key: 'service', - label: 'Service', - properties: ['environment'], - rows: [{ key: 'service.environment', value: 'production' }] - } - ]); - }); - - it('returns empty when no item matches', () => { - const filteredItems = filterSectionsByTerm(metadataItems, 'post'); - expect(filteredItems).toEqual([]); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/helper.ts b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/helper.ts deleted file mode 100644 index ef329abafa61b..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/helper.ts +++ /dev/null @@ -1,55 +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 { get, pick, isEmpty } from 'lodash'; -import { Section } from './sections'; -import { Transaction } from '../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; -import { APMError } from '../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; -import { Span } from '../../../../../../../plugins/apm/typings/es_schemas/ui/span'; -import { flattenObject, KeyValuePair } from '../../../utils/flattenObject'; - -export type SectionsWithRows = ReturnType<typeof getSectionsWithRows>; - -export const getSectionsWithRows = ( - sections: Section[], - apmDoc: Transaction | APMError | Span -) => { - return sections - .map(section => { - const sectionData: Record<string, unknown> = get(apmDoc, section.key); - const filteredData: - | Record<string, unknown> - | undefined = section.properties - ? pick(sectionData, section.properties) - : sectionData; - - const rows: KeyValuePair[] = flattenObject(filteredData, section.key); - return { ...section, rows }; - }) - .filter(({ required, rows }) => required || !isEmpty(rows)); -}; - -export const filterSectionsByTerm = ( - sections: SectionsWithRows, - searchTerm: string -) => { - if (!searchTerm) { - return sections; - } - return sections - .map(section => { - const { rows = [] } = section; - const filteredRows = rows.filter(({ key, value }) => { - const valueAsString = String(value).toLowerCase(); - return ( - key.toLowerCase().includes(searchTerm) || - valueAsString.includes(searchTerm) - ); - }); - return { ...section, rows: filteredRows }; - }) - .filter(({ rows }) => !isEmpty(rows)); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/index.test.ts b/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/index.test.ts deleted file mode 100644 index 9b6d74033e1c5..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/index.test.ts +++ /dev/null @@ -1,173 +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 { IStackframe } from '../../../../../../../../plugins/apm/typings/es_schemas/raw/fields/stackframe'; -import { getGroupedStackframes } from '../index'; -import stacktracesMock from './stacktraces.json'; - -describe('Stacktrace/index', () => { - describe('getGroupedStackframes', () => { - it('should collapse the library frames into a set of grouped stackframes', () => { - const result = getGroupedStackframes(stacktracesMock as IStackframe[]); - expect(result).toMatchSnapshot(); - }); - - it('should group stackframes when `library_frame` is identical and `exclude_from_grouping` is false', () => { - const stackframes = [ - { - library_frame: false, - exclude_from_grouping: false, - filename: 'file-a.txt' - }, - { - library_frame: false, - exclude_from_grouping: false, - filename: 'file-b.txt' - }, - { - library_frame: true, - exclude_from_grouping: false, - filename: 'file-c.txt' - }, - { - library_frame: true, - exclude_from_grouping: false, - filename: 'file-d.txt' - } - ] as IStackframe[]; - - const result = getGroupedStackframes(stackframes); - - expect(result).toEqual([ - { - excludeFromGrouping: false, - isLibraryFrame: false, - stackframes: [ - { - exclude_from_grouping: false, - filename: 'file-a.txt', - library_frame: false - }, - { - exclude_from_grouping: false, - filename: 'file-b.txt', - library_frame: false - } - ] - }, - { - excludeFromGrouping: false, - isLibraryFrame: true, - stackframes: [ - { - exclude_from_grouping: false, - filename: 'file-c.txt', - library_frame: true - }, - { - exclude_from_grouping: false, - filename: 'file-d.txt', - library_frame: true - } - ] - } - ]); - }); - - it('should not group stackframes when `library_frame` is the different', () => { - const stackframes = [ - { - library_frame: false, - exclude_from_grouping: false, - filename: 'file-a.txt' - }, - { - library_frame: true, - exclude_from_grouping: false, - filename: 'file-b.txt' - } - ] as IStackframe[]; - const result = getGroupedStackframes(stackframes); - expect(result).toEqual([ - { - excludeFromGrouping: false, - isLibraryFrame: false, - stackframes: [ - { - exclude_from_grouping: false, - filename: 'file-a.txt', - library_frame: false - } - ] - }, - { - excludeFromGrouping: false, - isLibraryFrame: true, - stackframes: [ - { - exclude_from_grouping: false, - filename: 'file-b.txt', - library_frame: true - } - ] - } - ]); - }); - - it('should not group stackframes when `exclude_from_grouping` is true', () => { - const stackframes = [ - { - library_frame: false, - exclude_from_grouping: false, - filename: 'file-a.txt' - }, - { - library_frame: false, - exclude_from_grouping: true, - filename: 'file-b.txt' - } - ] as IStackframe[]; - const result = getGroupedStackframes(stackframes); - expect(result).toEqual([ - { - excludeFromGrouping: false, - isLibraryFrame: false, - stackframes: [ - { - exclude_from_grouping: false, - filename: 'file-a.txt', - library_frame: false - } - ] - }, - { - excludeFromGrouping: true, - isLibraryFrame: false, - stackframes: [ - { - exclude_from_grouping: true, - filename: 'file-b.txt', - library_frame: false - } - ] - } - ]); - }); - - it('should handle empty stackframes', () => { - const result = getGroupedStackframes([] as IStackframe[]); - expect(result).toHaveLength(0); - }); - - it('should handle one stackframe', () => { - const result = getGroupedStackframes([ - stacktracesMock[0] - ] as IStackframe[]); - expect(result).toHaveLength(1); - expect(result[0].stackframes).toHaveLength(1); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/index.tsx deleted file mode 100644 index 141ed544a6166..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/index.tsx +++ /dev/null @@ -1,103 +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 { EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { isEmpty, last } from 'lodash'; -import React, { Fragment } from 'react'; -import { IStackframe } from '../../../../../../../plugins/apm/typings/es_schemas/raw/fields/stackframe'; -import { EmptyMessage } from '../../shared/EmptyMessage'; -import { LibraryStacktrace } from './LibraryStacktrace'; -import { Stackframe } from './Stackframe'; - -interface Props { - stackframes?: IStackframe[]; - codeLanguage?: string; -} - -export function Stacktrace({ stackframes = [], codeLanguage }: Props) { - if (isEmpty(stackframes)) { - return ( - <EmptyMessage - heading={i18n.translate( - 'xpack.apm.stacktraceTab.noStacktraceAvailableLabel', - { - defaultMessage: 'No stack trace available.' - } - )} - hideSubheading - /> - ); - } - - const groups = getGroupedStackframes(stackframes); - - return ( - <Fragment> - {groups.map((group, i) => { - // library frame - if (group.isLibraryFrame && groups.length > 1) { - return ( - <Fragment key={i}> - <EuiSpacer size="m" /> - <LibraryStacktrace - id={i.toString()} - stackframes={group.stackframes} - codeLanguage={codeLanguage} - /> - <EuiSpacer size="m" /> - </Fragment> - ); - } - - // non-library frame - return group.stackframes.map((stackframe, idx) => ( - <Fragment key={`${i}-${idx}`}> - {idx > 0 && <EuiSpacer size="m" />} - <Stackframe - codeLanguage={codeLanguage} - id={`${i}-${idx}`} - initialIsOpen={i === 0 && groups.length > 1} - stackframe={stackframe} - /> - </Fragment> - )); - })} - <EuiSpacer size="m" /> - </Fragment> - ); -} - -interface StackframesGroup { - isLibraryFrame: boolean; - excludeFromGrouping: boolean; - stackframes: IStackframe[]; -} - -export function getGroupedStackframes(stackframes: IStackframe[]) { - return stackframes.reduce((acc, stackframe) => { - const prevGroup = last(acc); - const shouldAppend = - prevGroup && - prevGroup.isLibraryFrame === stackframe.library_frame && - !prevGroup.excludeFromGrouping && - !stackframe.exclude_from_grouping; - - // append to group - if (shouldAppend) { - prevGroup.stackframes.push(stackframe); - return acc; - } - - // create new group - acc.push({ - isLibraryFrame: Boolean(stackframe.library_frame), - excludeFromGrouping: Boolean(stackframe.exclude_from_grouping), - stackframes: [stackframe] - }); - return acc; - }, [] as StackframesGroup[]); -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Summary/index.tsx deleted file mode 100644 index ef99f3a4933a7..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/index.tsx +++ /dev/null @@ -1,47 +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 { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; -import styled from 'styled-components'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; -import { px, units } from '../../../../public/style/variables'; -import { Maybe } from '../../../../../../../plugins/apm/typings/common'; - -interface Props { - items: Array<Maybe<React.ReactElement>>; -} - -// TODO: Light/Dark theme (@see https://github.com/elastic/kibana/issues/44840) -const theme = euiLightVars; - -const Item = styled(EuiFlexItem)` - flex-wrap: nowrap; - border-right: 1px solid ${theme.euiColorLightShade}; - padding-right: ${px(units.half)}; - flex-flow: row nowrap; - line-height: 1.5; - align-items: center !important; - &:last-child { - border-right: none; - padding-right: 0; - } -`; - -const Summary = ({ items }: Props) => { - const filteredItems = items.filter(Boolean) as React.ReactElement[]; - - return ( - <EuiFlexGrid gutterSize="s"> - {filteredItems.map((item, index) => ( - <Item key={index} grow={false}> - {item} - </Item> - ))} - </EuiFlexGrid> - ); -}; - -export { Summary }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx deleted file mode 100644 index 9d1eeb9a3136d..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx +++ /dev/null @@ -1,128 +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 { render, act, fireEvent } from '@testing-library/react'; -import { CustomLink } from '.'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { - expectTextsInDocument, - expectTextsNotInDocument -} from '../../../../utils/testHelpers'; -import { CustomLink as CustomLinkType } from '../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; - -describe('Custom links', () => { - it('shows empty message when no custom link is available', () => { - const component = render( - <CustomLink - customLinks={[]} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.SUCCESS} - /> - ); - - expectTextsInDocument(component, [ - 'No custom links found. Set up your own custom links, e.g., a link to a specific Dashboard or external link.' - ]); - expectTextsNotInDocument(component, ['Create']); - }); - - it('shows loading while custom links are fetched', () => { - const { getByTestId } = render( - <CustomLink - customLinks={[]} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.LOADING} - /> - ); - expect(getByTestId('loading-spinner')).toBeInTheDocument(); - }); - - it('shows first 3 custom links available', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'foo' }, - { id: '2', label: 'bar', url: 'bar' }, - { id: '3', label: 'baz', url: 'baz' }, - { id: '4', label: 'qux', url: 'qux' } - ] as CustomLinkType[]; - const component = render( - <CustomLink - customLinks={customLinks} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.SUCCESS} - /> - ); - expectTextsInDocument(component, ['foo', 'bar', 'baz']); - expectTextsNotInDocument(component, ['qux']); - }); - - it('clicks on See more button', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'foo' }, - { id: '2', label: 'bar', url: 'bar' }, - { id: '3', label: 'baz', url: 'baz' }, - { id: '4', label: 'qux', url: 'qux' } - ] as CustomLinkType[]; - const onSeeMoreClickMock = jest.fn(); - const component = render( - <CustomLink - customLinks={customLinks} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={onSeeMoreClickMock} - status={FETCH_STATUS.SUCCESS} - /> - ); - expect(onSeeMoreClickMock).not.toHaveBeenCalled(); - act(() => { - fireEvent.click(component.getByText('See more')); - }); - expect(onSeeMoreClickMock).toHaveBeenCalled(); - }); - - describe('create custom link buttons', () => { - it('shows create button below empty message', () => { - const component = render( - <CustomLink - customLinks={[]} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.SUCCESS} - /> - ); - - expectTextsInDocument(component, ['Create custom link']); - expectTextsNotInDocument(component, ['Create']); - }); - it('shows create button besides the title', () => { - const customLinks = [ - { id: '1', label: 'foo', url: 'foo' }, - { id: '2', label: 'bar', url: 'bar' }, - { id: '3', label: 'baz', url: 'baz' }, - { id: '4', label: 'qux', url: 'qux' } - ] as CustomLinkType[]; - const component = render( - <CustomLink - customLinks={customLinks} - transaction={({} as unknown) as Transaction} - onCreateCustomLinkClick={jest.fn()} - onSeeMoreClick={jest.fn()} - status={FETCH_STATUS.SUCCESS} - /> - ); - expectTextsInDocument(component, ['Create']); - expectTextsNotInDocument(component, ['Create custom link']); - }); - }); -}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx deleted file mode 100644 index 38b672a181fce..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx +++ /dev/null @@ -1,128 +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 { - EuiText, - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiButtonEmpty -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import { isEmpty } from 'lodash'; -import { CustomLink as CustomLinkType } from '../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; -import { - ActionMenuDivider, - SectionSubtitle -} from '../../../../../../../../plugins/observability/public'; -import { CustomLinkSection } from './CustomLinkSection'; -import { ManageCustomLink } from './ManageCustomLink'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { LoadingStatePrompt } from '../../LoadingStatePrompt'; -import { px } from '../../../../style/variables'; - -const SeeMoreButton = styled.button<{ show: boolean }>` - display: ${props => (props.show ? 'flex' : 'none')}; - align-items: center; - width: 100%; - justify-content: space-between; - &:hover { - text-decoration: underline; - } -`; - -export const CustomLink = ({ - customLinks, - status, - onCreateCustomLinkClick, - onSeeMoreClick, - transaction -}: { - customLinks: CustomLinkType[]; - status: FETCH_STATUS; - onCreateCustomLinkClick: () => void; - onSeeMoreClick: () => void; - transaction: Transaction; -}) => { - const renderEmptyPrompt = ( - <> - <EuiText size="xs" grow={false} style={{ width: px(300) }}> - {i18n.translate('xpack.apm.customLink.empty', { - defaultMessage: - 'No custom links found. Set up your own custom links, e.g., a link to a specific Dashboard or external link.' - })} - </EuiText> - <EuiSpacer size="s" /> - <EuiButtonEmpty - iconType="plusInCircle" - size="xs" - onClick={onCreateCustomLinkClick} - > - {i18n.translate('xpack.apm.customLink.buttom.create', { - defaultMessage: 'Create custom link' - })} - </EuiButtonEmpty> - </> - ); - - const renderCustomLinkBottomSection = isEmpty(customLinks) ? ( - renderEmptyPrompt - ) : ( - <SeeMoreButton onClick={onSeeMoreClick} show={customLinks.length > 3}> - <EuiText size="s"> - {i18n.translate('xpack.apm.transactionActionMenu.customLink.seeMore', { - defaultMessage: 'See more' - })} - </EuiText> - <EuiIcon type="arrowRight" /> - </SeeMoreButton> - ); - - return ( - <> - <ActionMenuDivider /> - <EuiFlexGroup> - <EuiFlexItem style={{ justifyContent: 'center' }}> - <EuiText size={'s'} grow={false}> - <h5> - {i18n.translate( - 'xpack.apm.transactionActionMenu.customLink.section', - { - defaultMessage: 'Custom Links' - } - )} - </h5> - </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <ManageCustomLink - onCreateCustomLinkClick={onCreateCustomLinkClick} - showCreateCustomLinkButton={!!customLinks.length} - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="s" /> - <SectionSubtitle> - {i18n.translate('xpack.apm.transactionActionMenu.customLink.subtitle', { - defaultMessage: 'Links will open in a new window.' - })} - </SectionSubtitle> - <CustomLinkSection - customLinks={customLinks.slice(0, 3)} - transaction={transaction} - /> - <EuiSpacer size="s" /> - {status === FETCH_STATUS.LOADING ? ( - <LoadingStatePrompt /> - ) : ( - renderCustomLinkBottomSection - )} - </> - ); -}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx deleted file mode 100644 index 50ea169c017f9..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx +++ /dev/null @@ -1,58 +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, { useMemo } from 'react'; -import numeral from '@elastic/numeral'; -import { throttle } from 'lodash'; -import { NOT_AVAILABLE_LABEL } from '../../../../../../../../plugins/apm/common/i18n'; -import { - Coordinate, - TimeSeries -} from '../../../../../../../../plugins/apm/typings/timeseries'; -import { Maybe } from '../../../../../../../../plugins/apm/typings/common'; -import { TransactionLineChart } from '../../charts/TransactionCharts/TransactionLineChart'; -import { asPercent } from '../../../../utils/formatters'; -import { unit } from '../../../../style/variables'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { useUiTracker } from '../../../../../../../../plugins/observability/public'; - -interface Props { - timeseries: TimeSeries[]; -} - -const tickFormatY = (y: Maybe<number>) => { - return numeral(y || 0).format('0 %'); -}; - -const formatTooltipValue = (coordinate: Coordinate) => { - return isValidCoordinateValue(coordinate.y) - ? asPercent(coordinate.y, 1) - : NOT_AVAILABLE_LABEL; -}; - -const TransactionBreakdownGraph: React.FC<Props> = props => { - const { timeseries } = props; - const trackApmEvent = useUiTracker({ app: 'apm' }); - const handleHover = useMemo( - () => - throttle(() => trackApmEvent({ metric: 'hover_breakdown_chart' }), 60000), - [trackApmEvent] - ); - - return ( - <TransactionLineChart - series={timeseries} - tickFormatY={tickFormatY} - formatTooltipValue={formatTooltipValue} - yMax={1} - height={unit * 12} - stacked={true} - onHover={handleHover} - /> - ); -}; - -export { TransactionBreakdownGraph }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx deleted file mode 100644 index 85f5f83fb920e..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx +++ /dev/null @@ -1,63 +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, { useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown'; -import { TransactionBreakdownHeader } from './TransactionBreakdownHeader'; -import { TransactionBreakdownKpiList } from './TransactionBreakdownKpiList'; -import { TransactionBreakdownGraph } from './TransactionBreakdownGraph'; -import { FETCH_STATUS } from '../../../hooks/useFetcher'; -import { useUiTracker } from '../../../../../../../plugins/observability/public'; - -const emptyMessage = i18n.translate('xpack.apm.transactionBreakdown.noData', { - defaultMessage: 'No data within this time range.' -}); - -const TransactionBreakdown: React.FC<{ - initialIsOpen?: boolean; -}> = ({ initialIsOpen }) => { - const [showChart, setShowChart] = useState(!!initialIsOpen); - const { data, status } = useTransactionBreakdown(); - const trackApmEvent = useUiTracker({ app: 'apm' }); - const { kpis, timeseries } = data; - const noHits = data.kpis.length === 0 && status === FETCH_STATUS.SUCCESS; - const showEmptyMessage = noHits && !showChart; - - return ( - <EuiPanel> - <EuiFlexGroup direction="column" gutterSize="s"> - <EuiFlexItem grow={false}> - <TransactionBreakdownHeader - showChart={showChart} - onToggleClick={() => { - setShowChart(!showChart); - if (showChart) { - trackApmEvent({ metric: 'hide_breakdown_chart' }); - } else { - trackApmEvent({ metric: 'show_breakdown_chart' }); - } - }} - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - {showEmptyMessage ? ( - <EuiText>{emptyMessage}</EuiText> - ) : ( - <TransactionBreakdownKpiList kpis={kpis} /> - )} - </EuiFlexItem> - {showChart ? ( - <EuiFlexItem grow={false}> - <TransactionBreakdownGraph timeseries={timeseries} /> - </EuiFlexItem> - ) : null} - </EuiFlexGroup> - </EuiPanel> - ); -}; - -export { TransactionBreakdown }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx deleted file mode 100644 index 077e6535a8b21..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx +++ /dev/null @@ -1,149 +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 { map } from 'lodash'; -import { EuiFieldNumber, EuiSelect } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; -import { - TRANSACTION_ALERT_AGGREGATION_TYPES, - ALERT_TYPES_CONFIG -} from '../../../../../../../plugins/apm/common/alert_types'; -import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; -import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; - -interface Params { - windowSize: number; - windowUnit: string; - threshold: number; - aggregationType: 'avg' | '95th' | '99th'; - serviceName: string; - transactionType: string; -} - -interface Props { - alertParams: Params; - setAlertParams: (key: string, value: any) => void; - setAlertProperty: (key: string, value: any) => void; -} - -export function TransactionDurationAlertTrigger(props: Props) { - const { setAlertParams, alertParams, setAlertProperty } = props; - - const { urlParams } = useUrlParams(); - - const transactionTypes = useServiceTransactionTypes(urlParams); - - if (!transactionTypes.length) { - return null; - } - - const defaults = { - threshold: 1500, - aggregationType: 'avg', - windowSize: 5, - windowUnit: 'm', - transactionType: transactionTypes[0] - }; - - const params = { - ...defaults, - ...alertParams - }; - - const fields = [ - <PopoverExpression - value={params.transactionType} - title={i18n.translate('xpack.apm.transactionDurationAlertTrigger.type', { - defaultMessage: 'Type' - })} - > - <EuiSelect - value={params.transactionType} - options={transactionTypes.map(key => { - return { - text: key, - value: key - }; - })} - onChange={e => - setAlertParams( - 'transactionType', - e.target.value as Params['transactionType'] - ) - } - compressed - /> - </PopoverExpression>, - <PopoverExpression - value={params.aggregationType} - title={i18n.translate('xpack.apm.transactionDurationAlertTrigger.when', { - defaultMessage: 'When' - })} - > - <EuiSelect - value={params.aggregationType} - options={map(TRANSACTION_ALERT_AGGREGATION_TYPES, (label, key) => { - return { - text: label, - value: key - }; - })} - onChange={e => - setAlertParams( - 'aggregationType', - e.target.value as Params['aggregationType'] - ) - } - compressed - /> - </PopoverExpression>, - <PopoverExpression - value={params.threshold ? `${params.threshold}ms` : ''} - title={i18n.translate( - 'xpack.apm.transactionDurationAlertTrigger.isAbove', - { - defaultMessage: 'is above' - } - )} - > - <EuiFieldNumber - value={params.threshold ?? ''} - onChange={e => setAlertParams('threshold', e.target.value)} - append={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { - defaultMessage: 'ms' - })} - compressed - /> - </PopoverExpression>, - <ForLastExpression - onChangeWindowSize={timeWindowSize => - setAlertParams('windowSize', timeWindowSize || '') - } - onChangeWindowUnit={timeWindowUnit => - setAlertParams('windowUnit', timeWindowUnit) - } - timeWindowSize={params.windowSize} - timeWindowUnit={params.windowUnit} - errors={{ - timeWindowSize: [], - timeWindowUnit: [] - }} - /> - ]; - - return ( - <ServiceAlertTrigger - alertTypeName={ALERT_TYPES_CONFIG['apm.transaction_duration'].name} - fields={fields} - defaults={defaults} - setAlertParams={setAlertParams} - setAlertProperty={setAlertProperty} - /> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx deleted file mode 100644 index 2ceac87d9aab3..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx +++ /dev/null @@ -1,105 +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 { EuiTitle } from '@elastic/eui'; -import React from 'react'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { GenericMetricsChart } from '../../../../../../../../plugins/apm/server/lib/metrics/transform_metrics_chart'; -// @ts-ignore -import CustomPlot from '../CustomPlot'; -import { - asDecimal, - asPercent, - asInteger, - asDynamicBytes, - getFixedByteFormatter, - asDuration -} from '../../../../utils/formatters'; -import { Coordinate } from '../../../../../../../../plugins/apm/typings/timeseries'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { useChartsSync } from '../../../../hooks/useChartsSync'; -import { Maybe } from '../../../../../../../../plugins/apm/typings/common'; - -interface Props { - start: Maybe<number | string>; - end: Maybe<number | string>; - chart: GenericMetricsChart; -} - -export function MetricsChart({ chart }: Props) { - const formatYValue = getYTickFormatter(chart); - const formatTooltip = getTooltipFormatter(chart); - - const transformedSeries = chart.series.map(series => ({ - ...series, - legendValue: formatYValue(series.overallValue) - })); - - const syncedChartProps = useChartsSync(); - - return ( - <React.Fragment> - <EuiTitle size="xs"> - <span>{chart.title}</span> - </EuiTitle> - <CustomPlot - {...syncedChartProps} - series={transformedSeries} - tickFormatY={formatYValue} - formatTooltipValue={formatTooltip} - yMax={chart.yUnit === 'percent' ? 1 : 'max'} - /> - </React.Fragment> - ); -} - -function getYTickFormatter(chart: GenericMetricsChart) { - switch (chart.yUnit) { - case 'bytes': { - const max = Math.max( - ...chart.series.map(({ data }) => - Math.max(...data.map(({ y }) => y || 0)) - ) - ); - return getFixedByteFormatter(max); - } - case 'percent': { - return (y: Maybe<number>) => asPercent(y || 0, 1); - } - case 'time': { - return (y: Maybe<number>) => asDuration(y); - } - case 'integer': { - return (y: Maybe<number>) => - isValidCoordinateValue(y) ? asInteger(y) : y; - } - default: { - return (y: Maybe<number>) => - isValidCoordinateValue(y) ? asDecimal(y) : y; - } - } -} - -function getTooltipFormatter({ yUnit }: GenericMetricsChart) { - switch (yUnit) { - case 'bytes': { - return (c: Coordinate) => asDynamicBytes(c.y); - } - case 'percent': { - return (c: Coordinate) => asPercent(c.y || 0, 1); - } - case 'time': { - return (c: Coordinate) => asDuration(c.y); - } - case 'integer': { - return (c: Coordinate) => - isValidCoordinateValue(c.y) ? asInteger(c.y) : c.y; - } - default: { - return (c: Coordinate) => - isValidCoordinateValue(c.y) ? asDecimal(c.y) : c.y; - } - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx deleted file mode 100644 index c9c31b05e264c..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx +++ /dev/null @@ -1,73 +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, { useCallback } from 'react'; -import { - Coordinate, - RectCoordinate -} from '../../../../../../../../../plugins/apm/typings/timeseries'; -import { useChartsSync } from '../../../../../hooks/useChartsSync'; -// @ts-ignore -import CustomPlot from '../../CustomPlot'; - -interface Props { - series: Array<{ - color: string; - title: React.ReactNode; - titleShort?: React.ReactNode; - data: Array<Coordinate | RectCoordinate>; - type: string; - }>; - truncateLegends?: boolean; - tickFormatY: (y: number) => React.ReactNode; - formatTooltipValue: (c: Coordinate) => React.ReactNode; - yMax?: string | number; - height?: number; - stacked?: boolean; - onHover?: () => void; -} - -const TransactionLineChart: React.FC<Props> = (props: Props) => { - const { - series, - tickFormatY, - formatTooltipValue, - yMax = 'max', - height, - truncateLegends, - stacked = false, - onHover - } = props; - - const syncedChartsProps = useChartsSync(); - - // combine callback for syncedChartsProps.onHover and props.onHover - const combinedOnHover = useCallback( - (hoverX: number) => { - if (onHover) { - onHover(); - } - return syncedChartsProps.onHover(hoverX); - }, - [syncedChartsProps, onHover] - ); - - return ( - <CustomPlot - series={series} - {...syncedChartsProps} - onHover={combinedOnHover} - tickFormatY={tickFormatY} - formatTooltipValue={formatTooltipValue} - yMax={yMax} - height={height} - truncateLegends={truncateLegends} - {...(stacked ? { stackBy: 'y' } : {})} - /> - ); -}; - -export { TransactionLineChart }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx deleted file mode 100644 index 368a39e4ad228..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ /dev/null @@ -1,269 +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 { - EuiFlexGrid, - EuiFlexGroup, - EuiFlexItem, - EuiIconTip, - EuiPanel, - EuiText, - EuiTitle, - EuiSpacer -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { Location } from 'history'; -import React, { Component } from 'react'; -import { isEmpty, flatten } from 'lodash'; -import styled from 'styled-components'; -import { NOT_AVAILABLE_LABEL } from '../../../../../../../../plugins/apm/common/i18n'; -import { - Coordinate, - TimeSeries -} from '../../../../../../../../plugins/apm/typings/timeseries'; -import { ITransactionChartData } from '../../../../selectors/chartSelectors'; -import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -import { - asInteger, - tpmUnit, - TimeFormatter, - getDurationFormatter -} from '../../../../utils/formatters'; -import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink'; -import { LicenseContext } from '../../../../context/LicenseContext'; -import { TransactionLineChart } from './TransactionLineChart'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { BrowserLineChart } from './BrowserLineChart'; -import { DurationByCountryMap } from './DurationByCountryMap'; -import { - TRANSACTION_PAGE_LOAD, - TRANSACTION_ROUTE_CHANGE, - TRANSACTION_REQUEST -} from '../../../../../../../../plugins/apm/common/transaction_types'; - -interface TransactionChartProps { - hasMLJob: boolean; - charts: ITransactionChartData; - location: Location; - urlParams: IUrlParams; -} - -const ShiftedIconWrapper = styled.span` - padding-right: 5px; - position: relative; - top: -1px; - display: inline-block; -`; - -const ShiftedEuiText = styled(EuiText)` - position: relative; - top: 5px; -`; - -export function getResponseTimeTickFormatter(formatter: TimeFormatter) { - return (t: number) => formatter(t).formatted; -} - -export function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { - return (p: Coordinate) => { - return isValidCoordinateValue(p.y) - ? formatter(p.y).formatted - : NOT_AVAILABLE_LABEL; - }; -} - -export function getMaxY(responseTimeSeries: TimeSeries[]) { - const coordinates = flatten( - responseTimeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) - ); - - const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); - - return Math.max(...numbers, 0); -} - -export class TransactionCharts extends Component<TransactionChartProps> { - public getTPMFormatter = (t: number) => { - const { urlParams } = this.props; - const unit = tpmUnit(urlParams.transactionType); - return `${asInteger(t)} ${unit}`; - }; - - public getTPMTooltipFormatter = (p: Coordinate) => { - return isValidCoordinateValue(p.y) - ? this.getTPMFormatter(p.y) - : NOT_AVAILABLE_LABEL; - }; - - public renderMLHeader(hasValidMlLicense: boolean | undefined) { - const { hasMLJob } = this.props; - if (!hasValidMlLicense || !hasMLJob) { - return null; - } - - const { serviceName, transactionType, kuery } = this.props.urlParams; - if (!serviceName) { - return null; - } - - const hasKuery = !isEmpty(kuery); - const icon = hasKuery ? ( - <EuiIconTip - aria-label="Warning" - type="alert" - color="warning" - content="The Machine learning results are hidden when the search bar is used for filtering" - /> - ) : ( - <EuiIconTip - content={i18n.translate( - 'xpack.apm.metrics.transactionChart.machineLearningTooltip', - { - defaultMessage: - 'The stream around the average duration shows the expected bounds. An annotation is shown for anomaly scores >= 75.' - } - )} - /> - ); - - return ( - <EuiFlexItem grow={false}> - <ShiftedEuiText size="xs"> - <ShiftedIconWrapper>{icon}</ShiftedIconWrapper> - <span> - {i18n.translate( - 'xpack.apm.metrics.transactionChart.machineLearningLabel', - { - defaultMessage: 'Machine learning:' - } - )}{' '} - </span> - <MLJobLink - serviceName={serviceName} - transactionType={transactionType} - > - View Job - </MLJobLink> - </ShiftedEuiText> - </EuiFlexItem> - ); - } - - public render() { - const { charts, urlParams } = this.props; - const { responseTimeSeries, tpmSeries } = charts; - const { transactionType } = urlParams; - const maxY = getMaxY(responseTimeSeries); - const formatter = getDurationFormatter(maxY); - - return ( - <> - <EuiFlexGrid columns={2} gutterSize="s"> - <EuiFlexItem data-cy={`transaction-duration-charts`}> - <EuiPanel> - <React.Fragment> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem> - <EuiTitle size="xs"> - <span>{responseTimeLabel(transactionType)}</span> - </EuiTitle> - </EuiFlexItem> - <LicenseContext.Consumer> - {license => - this.renderMLHeader(license?.getFeature('ml').isAvailable) - } - </LicenseContext.Consumer> - </EuiFlexGroup> - <TransactionLineChart - series={responseTimeSeries} - tickFormatY={getResponseTimeTickFormatter(formatter)} - formatTooltipValue={getResponseTimeTooltipFormatter( - formatter - )} - /> - </React.Fragment> - </EuiPanel> - </EuiFlexItem> - - <EuiFlexItem style={{ flexShrink: 1 }}> - <EuiPanel> - <React.Fragment> - <EuiTitle size="xs"> - <span>{tpmLabel(transactionType)}</span> - </EuiTitle> - <TransactionLineChart - series={tpmSeries} - tickFormatY={this.getTPMFormatter} - formatTooltipValue={this.getTPMTooltipFormatter} - truncateLegends - /> - </React.Fragment> - </EuiPanel> - </EuiFlexItem> - </EuiFlexGrid> - {transactionType === TRANSACTION_PAGE_LOAD && ( - <> - <EuiSpacer size="s" /> - <EuiFlexGrid columns={2} gutterSize="s"> - <EuiFlexItem> - <EuiPanel> - <DurationByCountryMap /> - </EuiPanel> - </EuiFlexItem> - <EuiFlexItem> - <EuiPanel> - <BrowserLineChart /> - </EuiPanel> - </EuiFlexItem> - </EuiFlexGrid> - </> - )} - </> - ); - } -} - -function tpmLabel(type?: string) { - return type === TRANSACTION_REQUEST - ? i18n.translate( - 'xpack.apm.metrics.transactionChart.requestsPerMinuteLabel', - { - defaultMessage: 'Requests per minute' - } - ) - : i18n.translate( - 'xpack.apm.metrics.transactionChart.transactionsPerMinuteLabel', - { - defaultMessage: 'Transactions per minute' - } - ); -} - -function responseTimeLabel(type?: string) { - switch (type) { - case TRANSACTION_PAGE_LOAD: - return i18n.translate( - 'xpack.apm.metrics.transactionChart.pageLoadTimesLabel', - { - defaultMessage: 'Page load times' - } - ); - case TRANSACTION_ROUTE_CHANGE: - return i18n.translate( - 'xpack.apm.metrics.transactionChart.routeChangeTimesLabel', - { - defaultMessage: 'Route change times' - } - ); - default: - return i18n.translate( - 'xpack.apm.metrics.transactionChart.transactionDurationLabel', - { - defaultMessage: 'Transaction duration' - } - ); - } -} diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx deleted file mode 100644 index acc3886586889..0000000000000 --- a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { createContext } from 'react'; -import { AppMountContext } from 'kibana/public'; -import { ApmPluginSetupDeps, ConfigSchema } from '../../new-platform/plugin'; - -export type AppMountContextBasePath = AppMountContext['core']['http']['basePath']; - -export interface ApmPluginContextValue { - config: ConfigSchema; - core: AppMountContext['core']; - plugins: ApmPluginSetupDeps; -} - -export const ApmPluginContext = createContext({} as ApmPluginContextValue); diff --git a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx deleted file mode 100644 index 62cdbd3bbc995..0000000000000 --- a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx +++ /dev/null @@ -1,30 +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 useObservable from 'react-use/lib/useObservable'; -import { ILicense } from '../../../../../../plugins/licensing/public'; -import { useApmPluginContext } from '../../hooks/useApmPluginContext'; -import { InvalidLicenseNotification } from './InvalidLicenseNotification'; - -export const LicenseContext = React.createContext<ILicense | undefined>( - undefined -); - -export function LicenseProvider({ children }: { children: React.ReactChild }) { - const { license$ } = useApmPluginContext().plugins.licensing; - const license = useObservable(license$); - // if license is not loaded yet, consider it valid - const hasInvalidLicense = license?.isActive === false; - - // if license is invalid show an error message - if (hasInvalidLicense) { - return <InvalidLicenseNotification />; - } - - // render rest of application and pass down license via context - return <LicenseContext.Provider value={license} children={children} />; -} diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts deleted file mode 100644 index b80db0e9ae073..0000000000000 --- a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts +++ /dev/null @@ -1,131 +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 { compact, pick } from 'lodash'; -import datemath from '@elastic/datemath'; -import { IUrlParams } from './types'; -import { ProcessorEvent } from '../../../../../../plugins/apm/common/processor_event'; - -interface PathParams { - processorEvent?: ProcessorEvent; - serviceName?: string; - errorGroupId?: string; - serviceNodeName?: string; - traceId?: string; -} - -export function getParsedDate(rawDate?: string, opts = {}) { - if (rawDate) { - const parsed = datemath.parse(rawDate, opts); - if (parsed) { - return parsed.toISOString(); - } - } -} - -export function getStart(prevState: IUrlParams, rangeFrom?: string) { - if (prevState.rangeFrom !== rangeFrom) { - return getParsedDate(rangeFrom); - } - return prevState.start; -} - -export function getEnd(prevState: IUrlParams, rangeTo?: string) { - if (prevState.rangeTo !== rangeTo) { - return getParsedDate(rangeTo, { roundUp: true }); - } - return prevState.end; -} - -export function toNumber(value?: string) { - if (value !== undefined) { - return parseInt(value, 10); - } -} - -export function toString(value?: string) { - if (value === '' || value === 'null' || value === 'undefined') { - return; - } - return value; -} - -export function toBoolean(value?: string) { - return value === 'true'; -} - -export function getPathAsArray(pathname: string = '') { - return compact(pathname.split('/')); -} - -export function removeUndefinedProps<T>(obj: T): Partial<T> { - return pick(obj, value => value !== undefined); -} - -export function getPathParams(pathname: string = ''): PathParams { - const paths = getPathAsArray(pathname); - const pageName = paths[0]; - // TODO: use react router's real match params instead of guessing the path order - - switch (pageName) { - case 'services': - let servicePageName = paths[2]; - const serviceName = paths[1]; - const serviceNodeName = paths[3]; - - if (servicePageName === 'nodes' && paths.length > 3) { - servicePageName = 'metrics'; - } - - switch (servicePageName) { - case 'transactions': - return { - processorEvent: ProcessorEvent.transaction, - serviceName - }; - case 'errors': - return { - processorEvent: ProcessorEvent.error, - serviceName, - errorGroupId: paths[3] - }; - case 'metrics': - return { - processorEvent: ProcessorEvent.metric, - serviceName, - serviceNodeName - }; - case 'nodes': - return { - processorEvent: ProcessorEvent.metric, - serviceName - }; - case 'service-map': - return { - serviceName - }; - default: - return {}; - } - - case 'traces': - return { - processorEvent: ProcessorEvent.transaction - }; - case 'link-to': - const link = paths[1]; - switch (link) { - case 'trace': - return { - traceId: paths[2] - }; - default: - return {}; - } - default: - return {}; - } -} diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/index.tsx deleted file mode 100644 index 588936039c2bc..0000000000000 --- a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/index.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { - createContext, - useMemo, - useCallback, - useRef, - useState -} from 'react'; -import { withRouter } from 'react-router-dom'; -import { uniqueId, mapValues } from 'lodash'; -import { IUrlParams } from './types'; -import { getParsedDate } from './helpers'; -import { resolveUrlParams } from './resolveUrlParams'; -import { UIFilters } from '../../../../../../plugins/apm/typings/ui_filters'; -import { - localUIFilterNames, - LocalUIFilterName - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../../plugins/apm/server/lib/ui_filters/local_ui_filters/config'; -import { pickKeys } from '../../utils/pickKeys'; -import { useDeepObjectIdentity } from '../../hooks/useDeepObjectIdentity'; - -interface TimeRange { - rangeFrom: string; - rangeTo: string; -} - -function useUiFilters(params: IUrlParams): UIFilters { - const { kuery, environment, ...urlParams } = params; - const localUiFilters = mapValues( - pickKeys(urlParams, ...localUIFilterNames), - val => (val ? val.split(',') : []) - ) as Partial<Record<LocalUIFilterName, string[]>>; - - return useDeepObjectIdentity({ kuery, environment, ...localUiFilters }); -} - -const defaultRefresh = (time: TimeRange) => {}; - -const UrlParamsContext = createContext({ - urlParams: {} as IUrlParams, - refreshTimeRange: defaultRefresh, - uiFilters: {} as UIFilters -}); - -const UrlParamsProvider: React.ComponentClass<{}> = withRouter( - ({ location, children }) => { - const refUrlParams = useRef(resolveUrlParams(location, {})); - - const { start, end, rangeFrom, rangeTo } = refUrlParams.current; - - const [, forceUpdate] = useState(''); - - const urlParams = useMemo( - () => - resolveUrlParams(location, { - start, - end, - rangeFrom, - rangeTo - }), - [location, start, end, rangeFrom, rangeTo] - ); - - refUrlParams.current = urlParams; - - const refreshTimeRange = useCallback( - (timeRange: TimeRange) => { - refUrlParams.current = { - ...refUrlParams.current, - start: getParsedDate(timeRange.rangeFrom), - end: getParsedDate(timeRange.rangeTo, { roundUp: true }) - }; - - forceUpdate(uniqueId()); - }, - [forceUpdate] - ); - - const uiFilters = useUiFilters(urlParams); - - const contextValue = useMemo(() => { - return { - urlParams, - refreshTimeRange, - uiFilters - }; - }, [urlParams, refreshTimeRange, uiFilters]); - - return ( - <UrlParamsContext.Provider children={children} value={contextValue} /> - ); - } -); - -export { UrlParamsContext, UrlParamsProvider, useUiFilters }; diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/types.ts b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/types.ts deleted file mode 100644 index acde09308ab46..0000000000000 --- a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { LocalUIFilterName } from '../../../../../../plugins/apm/server/lib/ui_filters/local_ui_filters/config'; -import { ProcessorEvent } from '../../../../../../plugins/apm/common/processor_event'; - -export type IUrlParams = { - detailTab?: string; - end?: string; - errorGroupId?: string; - flyoutDetailTab?: string; - kuery?: string; - environment?: string; - rangeFrom?: string; - rangeTo?: string; - refreshInterval?: number; - refreshPaused?: boolean; - serviceName?: string; - sortDirection?: string; - sortField?: string; - start?: string; - traceId?: string; - transactionId?: string; - transactionName?: string; - transactionType?: string; - waterfallItemId?: string; - page?: number; - pageSize?: number; - serviceNodeName?: string; - searchTerm?: string; - processorEvent?: ProcessorEvent; - traceIdLink?: string; -} & Partial<Record<LocalUIFilterName, string>>; diff --git a/x-pack/legacy/plugins/apm/public/index.scss b/x-pack/legacy/plugins/apm/public/index.scss deleted file mode 100644 index 04a070c304d6f..0000000000000 --- a/x-pack/legacy/plugins/apm/public/index.scss +++ /dev/null @@ -1,16 +0,0 @@ -// Import the EUI global scope so we can use EUI constants -@import 'src/legacy/ui/public/styles/_styling_constants'; - -/* APM plugin styles */ - -// Prefix all styles with "apm" to avoid conflicts. -// Examples -// apmChart -// apmChart__legend -// apmChart__legend--small -// apmChart__legend-isLoading - -.apmReactRoot { - overflow-x: auto; - height: 100%; -} diff --git a/x-pack/legacy/plugins/apm/public/index.tsx b/x-pack/legacy/plugins/apm/public/index.tsx deleted file mode 100644 index 59b2fedaafba6..0000000000000 --- a/x-pack/legacy/plugins/apm/public/index.tsx +++ /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 { npSetup, npStart } from 'ui/new_platform'; -import 'react-vis/dist/style.css'; -import { PluginInitializerContext } from 'kibana/public'; -import 'ui/autoload/all'; -import chrome from 'ui/chrome'; -import { plugin } from './new-platform'; -import { REACT_APP_ROOT_ID } from './new-platform/plugin'; -import './style/global_overrides.css'; -import template from './templates/index.html'; - -// This will be moved to core.application.register when the new platform -// migration is complete. -// @ts-ignore -chrome.setRootTemplate(template); - -const checkForRoot = () => { - return new Promise(resolve => { - const ready = !!document.getElementById(REACT_APP_ROOT_ID); - if (ready) { - resolve(); - } else { - setTimeout(() => resolve(checkForRoot()), 10); - } - }); -}; -checkForRoot().then(() => { - const pluginInstance = plugin({} as PluginInitializerContext); - pluginInstance.setup(npSetup.core, npSetup.plugins); - pluginInstance.start(npStart.core, npStart.plugins); -}); diff --git a/x-pack/legacy/plugins/apm/public/legacy_register_feature.ts b/x-pack/legacy/plugins/apm/public/legacy_register_feature.ts deleted file mode 100644 index f12865399054e..0000000000000 --- a/x-pack/legacy/plugins/apm/public/legacy_register_feature.ts +++ /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. - */ - -import { npSetup } from 'ui/new_platform'; -import { featureCatalogueEntry } from './new-platform/featureCatalogueEntry'; - -const { - core, - plugins: { home } -} = npSetup; -const apmUiEnabled = core.injectedMetadata.getInjectedVar( - 'apmUiEnabled' -) as boolean; - -if (apmUiEnabled) { - home.featureCatalogue.register(featureCatalogueEntry); -} - -home.environment.update({ - apmUi: apmUiEnabled -}); diff --git a/x-pack/legacy/plugins/apm/public/new-platform/getConfigFromInjectedMetadata.ts b/x-pack/legacy/plugins/apm/public/new-platform/getConfigFromInjectedMetadata.ts deleted file mode 100644 index 6dc77f7733b2d..0000000000000 --- a/x-pack/legacy/plugins/apm/public/new-platform/getConfigFromInjectedMetadata.ts +++ /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. - */ - -import { npStart } from 'ui/new_platform'; -import { ConfigSchema } from './plugin'; - -const { core } = npStart; - -export function getConfigFromInjectedMetadata(): ConfigSchema { - const { - apmIndexPatternTitle, - apmServiceMapEnabled, - apmUiEnabled - } = core.injectedMetadata.getInjectedVars(); - - return { - indexPatternTitle: `${apmIndexPatternTitle}`, - serviceMapEnabled: !!apmServiceMapEnabled, - ui: { enabled: !!apmUiEnabled } - }; -} diff --git a/x-pack/legacy/plugins/apm/public/new-platform/index.tsx b/x-pack/legacy/plugins/apm/public/new-platform/index.tsx deleted file mode 100644 index 0674dc48316f4..0000000000000 --- a/x-pack/legacy/plugins/apm/public/new-platform/index.tsx +++ /dev/null @@ -1,13 +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 { PluginInitializer } from '../../../../../../src/core/public'; -import { ApmPlugin, ApmPluginSetup, ApmPluginStart } from './plugin'; - -export const plugin: PluginInitializer< - ApmPluginSetup, - ApmPluginStart -> = pluginInitializerContext => new ApmPlugin(pluginInitializerContext); diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx deleted file mode 100644 index 80a45ba66c4fa..0000000000000 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ /dev/null @@ -1,203 +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 { ApmRoute } from '@elastic/apm-rum-react'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import ReactDOM from 'react-dom'; -import { Route, Router, Switch } from 'react-router-dom'; -import styled from 'styled-components'; -import { - CoreSetup, - CoreStart, - Plugin, - PluginInitializerContext -} from '../../../../../../src/core/public'; -import { DataPublicPluginSetup } from '../../../../../../src/plugins/data/public'; -import { HomePublicPluginSetup } from '../../../../../../src/plugins/home/public'; -import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; -import { PluginSetupContract as AlertingPluginPublicSetup } from '../../../../../plugins/alerting/public'; -import { AlertType } from '../../../../../plugins/apm/common/alert_types'; -import { LicensingPluginSetup } from '../../../../../plugins/licensing/public'; -import { - AlertsContextProvider, - TriggersAndActionsUIPublicPluginSetup -} from '../../../../../plugins/triggers_actions_ui/public'; -import { APMIndicesPermission } from '../components/app/APMIndicesPermission'; -import { routes } from '../components/app/Main/route_config'; -import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange'; -import { UpdateBreadcrumbs } from '../components/app/Main/UpdateBreadcrumbs'; -import { ErrorRateAlertTrigger } from '../components/shared/ErrorRateAlertTrigger'; -import { TransactionDurationAlertTrigger } from '../components/shared/TransactionDurationAlertTrigger'; -import { ApmPluginContext } from '../context/ApmPluginContext'; -import { LicenseProvider } from '../context/LicenseContext'; -import { LoadingIndicatorProvider } from '../context/LoadingIndicatorContext'; -import { LocationProvider } from '../context/LocationContext'; -import { MatchedRouteProvider } from '../context/MatchedRouteContext'; -import { UrlParamsProvider } from '../context/UrlParamsContext'; -import { createCallApmApi } from '../services/rest/createCallApmApi'; -import { createStaticIndexPattern } from '../services/rest/index_pattern'; -import { px, unit, units } from '../style/variables'; -import { history } from '../utils/history'; -import { featureCatalogueEntry } from './featureCatalogueEntry'; -import { getConfigFromInjectedMetadata } from './getConfigFromInjectedMetadata'; -import { setHelpExtension } from './setHelpExtension'; -import { toggleAppLinkInNav } from './toggleAppLinkInNav'; -import { setReadonlyBadge } from './updateBadge'; - -export const REACT_APP_ROOT_ID = 'react-apm-root'; - -const MainContainer = styled.div` - min-width: ${px(unit * 50)}; - padding: ${px(units.plus)}; - height: 100%; -`; - -const App = () => { - return ( - <MainContainer data-test-subj="apmMainContainer" role="main"> - <UpdateBreadcrumbs routes={routes} /> - <Route component={ScrollToTopOnPathChange} /> - <APMIndicesPermission> - <Switch> - {routes.map((route, i) => ( - <ApmRoute key={i} {...route} /> - ))} - </Switch> - </APMIndicesPermission> - </MainContainer> - ); -}; - -export type ApmPluginSetup = void; -export type ApmPluginStart = void; - -export interface ApmPluginSetupDeps { - alerting?: AlertingPluginPublicSetup; - data: DataPublicPluginSetup; - home: HomePublicPluginSetup; - licensing: LicensingPluginSetup; - triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; -} - -export interface ConfigSchema { - indexPatternTitle: string; - serviceMapEnabled: boolean; - ui: { - enabled: boolean; - }; -} - -export class ApmPlugin - implements Plugin<ApmPluginSetup, ApmPluginStart, ApmPluginSetupDeps, {}> { - // When we switch over from the old platform to new platform the plugins will - // be coming from setup instead of start, since that's where we do - // `core.application.register`. During the transitions we put plugins on an - // instance property so we can use it in start. - setupPlugins: ApmPluginSetupDeps = {} as ApmPluginSetupDeps; - - constructor( - // @ts-ignore Not using initializerContext now, but will be once NP - // migration is complete. - private readonly initializerContext: PluginInitializerContext<ConfigSchema> - ) {} - - // Take the DOM element as the constructor, so we can mount the app. - public setup(_core: CoreSetup, plugins: ApmPluginSetupDeps) { - plugins.home.featureCatalogue.register(featureCatalogueEntry); - this.setupPlugins = plugins; - } - - public start(core: CoreStart) { - const i18nCore = core.i18n; - const plugins = this.setupPlugins; - createCallApmApi(core.http); - - // Once we're actually an NP plugin we'll get the config from the - // initializerContext like: - // - // const config = this.initializerContext.config.get<ConfigSchema>(); - // - // Until then we use a shim to get it from legacy injectedMetadata: - const config = getConfigFromInjectedMetadata(); - - // render APM feedback link in global help menu - setHelpExtension(core); - setReadonlyBadge(core); - toggleAppLinkInNav(core, config); - - const apmPluginContextValue = { - config, - core, - plugins - }; - - plugins.triggers_actions_ui.alertTypeRegistry.register({ - id: AlertType.ErrorRate, - name: i18n.translate('xpack.apm.alertTypes.errorRate', { - defaultMessage: 'Error rate' - }), - iconClass: 'bell', - alertParamsExpression: ErrorRateAlertTrigger, - validate: () => ({ - errors: [] - }) - }); - - plugins.triggers_actions_ui.alertTypeRegistry.register({ - id: AlertType.TransactionDuration, - name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { - defaultMessage: 'Transaction duration' - }), - iconClass: 'bell', - alertParamsExpression: TransactionDurationAlertTrigger, - validate: () => ({ - errors: [] - }) - }); - - ReactDOM.render( - <ApmPluginContext.Provider value={apmPluginContextValue}> - <AlertsContextProvider - value={{ - http: core.http, - docLinks: core.docLinks, - toastNotifications: core.notifications.toasts, - actionTypeRegistry: plugins.triggers_actions_ui.actionTypeRegistry, - alertTypeRegistry: plugins.triggers_actions_ui.alertTypeRegistry - }} - > - <KibanaContextProvider services={{ ...core, ...plugins }}> - <i18nCore.Context> - <Router history={history}> - <LocationProvider> - <MatchedRouteProvider routes={routes}> - <UrlParamsProvider> - <LoadingIndicatorProvider> - <LicenseProvider> - <App /> - </LicenseProvider> - </LoadingIndicatorProvider> - </UrlParamsProvider> - </MatchedRouteProvider> - </LocationProvider> - </Router> - </i18nCore.Context> - </KibanaContextProvider> - </AlertsContextProvider> - </ApmPluginContext.Provider>, - document.getElementById(REACT_APP_ROOT_ID) - ); - - // create static index pattern and store as saved object. Not needed by APM UI but for legacy reasons in Discover, Dashboard etc. - createStaticIndexPattern().catch(e => { - // eslint-disable-next-line no-console - console.log('Error fetching static index pattern', e); - }); - } - - public stop() {} -} diff --git a/x-pack/legacy/plugins/apm/public/style/global_overrides.css b/x-pack/legacy/plugins/apm/public/style/global_overrides.css deleted file mode 100644 index 75b4532f7c9a1..0000000000000 --- a/x-pack/legacy/plugins/apm/public/style/global_overrides.css +++ /dev/null @@ -1,34 +0,0 @@ -/* -Hide unused secondary Kibana navigation -*/ -.kuiLocalNav { - min-height: initial; -} - -.kuiLocalNavRow.kuiLocalNavRow--secondary { - display: none; -} - -/* -Remove unnecessary space below the navigation dropdown -*/ -.kuiLocalDropdown { - margin-bottom: 0; - border-bottom: none; -} - -/* -Hide the "0-10 of 100" text in KUIPager component for all KUIControlledTable -*/ -.kuiControlledTable .kuiPagerText { - display: none; -} - -/* -Hide default dashed gridlines in EUI chart component for all APM graphs -*/ - -.rv-xy-plot__grid-lines__line { - stroke-opacity: 1; - stroke-dasharray: 1; -} diff --git a/x-pack/legacy/plugins/apm/public/templates/index.html b/x-pack/legacy/plugins/apm/public/templates/index.html deleted file mode 100644 index 78e0ade3ad624..0000000000000 --- a/x-pack/legacy/plugins/apm/public/templates/index.html +++ /dev/null @@ -1 +0,0 @@ -<div id="react-apm-root" class="apmReactRoot"></div> diff --git a/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/tsconfig.json b/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/tsconfig.json deleted file mode 100644 index 8f6b0f35e4b52..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "include": [ - "./plugins/apm/**/*", - "./legacy/plugins/apm/**/*", - "./typings/**/*" - ], - "exclude": [ - "**/__fixtures__/**/*", - "./legacy/plugins/apm/e2e/cypress/**/*" - ] -} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts deleted file mode 100644 index bdc57eac412fc..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts +++ /dev/null @@ -1,208 +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. - */ - -// This script downloads the telemetry mapping, runs the APM telemetry tasks, -// generates a bunch of randomized data based on the downloaded sample, -// and uploads it to a cluster of your choosing in the same format as it is -// stored in the telemetry cluster. Its purpose is twofold: -// - Easier testing of the telemetry tasks -// - Validate whether we can run the queries we want to on the telemetry data - -import fs from 'fs'; -import path from 'path'; -// @ts-ignore -import { Octokit } from '@octokit/rest'; -import { merge, chunk, flatten, pick, identity } from 'lodash'; -import axios from 'axios'; -import yaml from 'js-yaml'; -import { Client } from 'elasticsearch'; -import { argv } from 'yargs'; -import { promisify } from 'util'; -import { Logger } from 'kibana/server'; -// @ts-ignore -import consoleStamp from 'console-stamp'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CollectTelemetryParams } from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; -import { downloadTelemetryTemplate } from './download-telemetry-template'; -import mapping from '../../mappings.json'; -import { generateSampleDocuments } from './generate-sample-documents'; - -consoleStamp(console, '[HH:MM:ss.l]'); - -const githubToken = process.env.GITHUB_TOKEN; - -if (!githubToken) { - throw new Error('GITHUB_TOKEN was not provided.'); -} - -const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); -const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); -const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); - -const xpackTelemetryIndexName = 'xpack-phone-home'; - -const loadedKibanaConfig = (yaml.safeLoad( - fs.readFileSync( - fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, - 'utf8' - ) -) || {}) as {}; - -const cliEsCredentials = pick( - { - 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, - 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, - 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST - }, - identity -) as { - 'elasticsearch.username': string; - 'elasticsearch.password': string; - 'elasticsearch.hosts': string; -}; - -const config = { - 'apm_oss.transactionIndices': 'apm-*', - 'apm_oss.metricsIndices': 'apm-*', - 'apm_oss.errorIndices': 'apm-*', - 'apm_oss.spanIndices': 'apm-*', - 'apm_oss.onboardingIndices': 'apm-*', - 'apm_oss.sourcemapIndices': 'apm-*', - 'elasticsearch.hosts': 'http://localhost:9200', - ...loadedKibanaConfig, - ...cliEsCredentials -}; - -async function uploadData() { - const octokit = new Octokit({ - auth: githubToken - }); - - const telemetryTemplate = await downloadTelemetryTemplate(octokit); - - const kibanaMapping = mapping['apm-telemetry']; - - const httpAuth = - config['elasticsearch.username'] && config['elasticsearch.password'] - ? { - username: config['elasticsearch.username'], - password: config['elasticsearch.password'] - } - : null; - - const client = new Client({ - host: config['elasticsearch.hosts'], - ...(httpAuth - ? { - httpAuth: `${httpAuth.username}:${httpAuth.password}` - } - : {}) - }); - - if (argv.clear) { - try { - await promisify(client.indices.delete.bind(client))({ - index: xpackTelemetryIndexName - }); - } catch (err) { - // 404 = index not found, totally okay - if (err.status !== 404) { - throw err; - } - } - } - - const axiosInstance = axios.create({ - baseURL: config['elasticsearch.hosts'], - ...(httpAuth ? { auth: httpAuth } : {}) - }); - - const newTemplate = merge(telemetryTemplate, { - settings: { - index: { mapping: { total_fields: { limit: 10000 } } } - } - }); - - // override apm mapping instead of merging - newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; - - await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); - - const sampleDocuments = await generateSampleDocuments({ - collectTelemetryParams: { - logger: (console as unknown) as Logger, - indices: { - ...config, - apmCustomLinkIndex: '.apm-custom-links', - apmAgentConfigurationIndex: '.apm-agent-configuration' - }, - search: body => { - return promisify(client.search.bind(client))({ - ...body, - requestTimeout: 120000 - }) as any; - }, - indicesStats: body => { - return promisify(client.indices.stats.bind(client))({ - ...body, - requestTimeout: 120000 - }) as any; - }, - transportRequest: (params => { - return axiosInstance[params.method](params.path); - }) as CollectTelemetryParams['transportRequest'] - } - }); - - const chunks = chunk(sampleDocuments, 250); - - await chunks.reduce<Promise<any>>((prev, documents) => { - return prev.then(async () => { - const body = flatten( - documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) - ); - - return promisify(client.bulk.bind(client))({ - body, - refresh: true - }).then((response: any) => { - if (response.errors) { - const firstError = response.items.filter( - (item: any) => item.index.status >= 400 - )[0].index.error; - throw new Error(`Failed to upload documents: ${firstError.reason} `); - } - }); - }); - }, Promise.resolve()); -} - -uploadData() - .catch(e => { - if ('response' in e) { - if (typeof e.response === 'string') { - // eslint-disable-next-line no-console - console.log(e.response); - } else { - // eslint-disable-next-line no-console - console.log( - JSON.stringify( - e.response, - ['status', 'statusText', 'headers', 'data'], - 2 - ) - ); - } - } else { - // eslint-disable-next-line no-console - console.log(e); - } - process.exit(1); - }) - .then(() => { - // eslint-disable-next-line no-console - console.log('Finished uploading generated telemetry data'); - }); diff --git a/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js b/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js index a81483d1e7a17..a679010c67092 100644 --- a/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js +++ b/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js @@ -77,8 +77,6 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { }; }); -jest.mock('plugins/interpreter/registries', () => ({})); - // Disabling this test due to https://github.com/elastic/eui/issues/2242 jest.mock( '../public/components/workpad_header/share_menu/flyout/__examples__/share_website_flyout.stories', diff --git a/x-pack/legacy/plugins/canvas/README.md b/x-pack/legacy/plugins/canvas/README.md index 8e91161c635c2..fbcd674f72181 100644 --- a/x-pack/legacy/plugins/canvas/README.md +++ b/x-pack/legacy/plugins/canvas/README.md @@ -48,7 +48,7 @@ Open your plugin's `index.js` file, and modify it to look something like this (b export default function (kibana) { return new kibana.Plugin({ // Tell Kibana that this plugin needs canvas and the Kibana interpreter - require: ['interpreter', 'canvas'], + require: ['canvas'], // The name of your plugin. Make this whatever you want. name: 'canvas_example', @@ -132,7 +132,7 @@ In your plugin's root `index.js` file, modify the `kibana.Plugin` definition to export default function (kibana) { return new kibana.Plugin({ // Tell Kibana that this plugin needs canvas and the Kibana interpreter - require: ['interpreter', 'canvas'], + require: ['canvas'], // The name of your plugin. Make this whatever you want. name: 'canvas_example', diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts index f5836fe91e040..592da1ff039a1 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts @@ -7,7 +7,7 @@ import { MAP_SAVED_OBJECT_TYPE } from '../../../../../plugins/maps/public'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../src/plugins/visualizations/public'; import { LENS_EMBEDDABLE_TYPE } from '../../../../../plugins/lens/common/constants'; -import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants'; +import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/plugins/discover/public'; export const EmbeddableTypes: { lens: string; 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 f03bc54757c3c..2b9bdb59afbdf 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 88a24186d6044..5031e8029957b 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 6b197148e6373..882d1e2ea58b9 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 2985a68cf855c..8fc55ddf9cc59 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<SavedLensInput>; 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 63dbae55790a3..74e41a030de35 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 cba19ce7da80f..df316d0dd182f 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<MapEmbeddableInput>; 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 67356dae5b3e3..9bd32202b563a 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 87dc7eb5e814c..d54ef3097f830 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 @@ -5,7 +5,7 @@ */ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; -import { SearchInput } from 'src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable'; +import { SearchInput } from 'src/plugins/discover/public'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -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<Partial<SearchInput> & { 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 754a113b87554..8327c1433b9af 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 d98fea2ec1be8..94c7a1c8a9eea 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 aeab0d50c31a7..834b9d195856c 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 249faf6141b46..ff7b56d8194df 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 94b2d5228665b..2b517664793a7 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 5cebae5bb669f..843e2bda47e12 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 ffb8bb4f3e2a7..2ab48fe002979 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,9 +4,14 @@ * 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'; +/* eslint-disable */ // @ts-ignore untyped local -import { buildESRequest } from '../../../server/lib/build_es_request'; +import { buildESRequest } from '../../../../../../plugins/canvas/server/lib/build_es_request'; +/* eslint-enable */ import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -14,7 +19,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 +50,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 5bff06bb3933b..180afc89322c3 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 @@ -6,9 +6,11 @@ import squel from 'squel'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; +/* eslint-disable */ // @ts-ignore untyped local -import { queryEsSQL } from '../../../server/lib/query_es_sql'; -import { Filter } from '../../../types'; +import { queryEsSQL } from '../../../../../../plugins/canvas/server/lib/query_es_sql'; +/* eslint-enable */ +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -20,7 +22,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 +74,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 cdb6b5af82015..7c9cb92ad009c 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 @@ -5,9 +5,11 @@ */ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; +/* eslint-disable */ // @ts-ignore untyped local -import { queryEsSQL } from '../../../server/lib/query_es_sql'; -import { Filter } from '../../../types'; +import { queryEsSQL } from '../../../../../../plugins/canvas/server/lib/query_es_sql'; +/* eslint-enable */ +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -16,7 +18,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 f61e2b6434697..63945ce7690f9 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/i18n/components.ts b/x-pack/legacy/plugins/canvas/i18n/components.ts index 7bd16c4933ce1..de16bc2101e8c 100644 --- a/x-pack/legacy/plugins/canvas/i18n/components.ts +++ b/x-pack/legacy/plugins/canvas/i18n/components.ts @@ -804,17 +804,6 @@ export const ComponentStrings = { }), }, SidebarHeader: { - getAlignmentMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.alignmentMenuItemLabel', { - defaultMessage: 'Alignment', - description: - 'This refers to the vertical (i.e. left, center, right) and horizontal (i.e. top, middle, bottom) ' + - 'alignment options of the selected elements', - }), - getBottomAlignMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.bottomAlignMenuItemLabel', { - defaultMessage: 'Bottom', - }), getBringForwardAriaLabel: () => i18n.translate('xpack.canvas.sidebarHeader.bringForwardArialLabel', { defaultMessage: 'Move element up one layer', @@ -823,56 +812,6 @@ export const ComponentStrings = { i18n.translate('xpack.canvas.sidebarHeader.bringToFrontArialLabel', { defaultMessage: 'Move element to top layer', }), - getCenterAlignMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.centerAlignMenuItemLabel', { - defaultMessage: 'Center', - description: 'This refers to alignment centered horizontally.', - }), - getContextMenuTitle: () => - i18n.translate('xpack.canvas.sidebarHeader.contextMenuAriaLabel', { - defaultMessage: 'Element options', - }), - getCreateElementModalTitle: () => - i18n.translate('xpack.canvas.sidebarHeader.createElementModalTitle', { - defaultMessage: 'Create new element', - }), - getDistributionMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.distributionMenutItemLabel', { - defaultMessage: 'Distribution', - description: - 'This refers to the options to evenly spacing the selected elements horizontall or vertically.', - }), - getGroupMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.groupMenuItemLabel', { - defaultMessage: 'Group', - description: 'This refers to grouping multiple selected elements.', - }), - getHorizontalDistributionMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.horizontalDistributionMenutItemLabel', { - defaultMessage: 'Horizontal', - }), - getLeftAlignMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.leftAlignMenuItemLabel', { - defaultMessage: 'Left', - }), - getMiddleAlignMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.middleAlignMenuItemLabel', { - defaultMessage: 'Middle', - description: 'This refers to alignment centered vertically.', - }), - getOrderMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.orderMenuItemLabel', { - defaultMessage: 'Order', - description: 'Refers to the order of the elements displayed on the page from front to back', - }), - getRightAlignMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.rightAlignMenuItemLabel', { - defaultMessage: 'Right', - }), - getSaveElementMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.savedElementMenuItemLabel', { - defaultMessage: 'Save as new element', - }), getSendBackwardAriaLabel: () => i18n.translate('xpack.canvas.sidebarHeader.sendBackwardArialLabel', { defaultMessage: 'Move element down one layer', @@ -881,19 +820,6 @@ export const ComponentStrings = { i18n.translate('xpack.canvas.sidebarHeader.sendToBackArialLabel', { defaultMessage: 'Move element to bottom layer', }), - getTopAlignMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.topAlignMenuItemLabel', { - defaultMessage: 'Top', - }), - getUngroupMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.ungroupMenuItemLabel', { - defaultMessage: 'Ungroup', - description: 'This refers to ungrouping a grouped element', - }), - getVerticalDistributionMenuItemLabel: () => - i18n.translate('xpack.canvas.sidebarHeader.verticalDistributionMenutItemLabel', { - defaultMessage: 'Vertical', - }), }, TextStylePicker: { getAlignCenterOption: () => @@ -1079,12 +1005,6 @@ export const ComponentStrings = { defaultMessage: 'Refresh elements', }), }, - WorkpadHeaderControlSettings: { - getButtonLabel: () => - i18n.translate('xpack.canvas.workpadHeaderControlSettings.buttonLabel', { - defaultMessage: 'Options', - }), - }, WorkpadHeaderCustomInterval: { getButtonLabel: () => i18n.translate('xpack.canvas.workpadHeaderCustomInterval.confirmButtonLabel', { @@ -1105,6 +1025,94 @@ export const ComponentStrings = { defaultMessage: 'Set a custom interval', }), }, + WorkpadHeaderEditMenu: { + getAlignmentMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.alignmentMenuItemLabel', { + defaultMessage: 'Alignment', + description: + 'This refers to the vertical (i.e. left, center, right) and horizontal (i.e. top, middle, bottom) ' + + 'alignment options of the selected elements', + }), + getBottomAlignMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.bottomAlignMenuItemLabel', { + defaultMessage: 'Bottom', + }), + getCenterAlignMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.centerAlignMenuItemLabel', { + defaultMessage: 'Center', + description: 'This refers to alignment centered horizontally.', + }), + getCreateElementModalTitle: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.createElementModalTitle', { + defaultMessage: 'Create new element', + }), + getDistributionMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.distributionMenutItemLabel', { + defaultMessage: 'Distribution', + description: + 'This refers to the options to evenly spacing the selected elements horizontall or vertically.', + }), + getEditMenuButtonLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.editMenuButtonLabel', { + defaultMessage: 'Edit', + }), + getEditMenuLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.editMenuLabel', { + defaultMessage: 'Edit options', + }), + getGroupMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.groupMenuItemLabel', { + defaultMessage: 'Group', + description: 'This refers to grouping multiple selected elements.', + }), + getHorizontalDistributionMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.horizontalDistributionMenutItemLabel', { + defaultMessage: 'Horizontal', + }), + getLeftAlignMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.leftAlignMenuItemLabel', { + defaultMessage: 'Left', + }), + getMiddleAlignMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.middleAlignMenuItemLabel', { + defaultMessage: 'Middle', + description: 'This refers to alignment centered vertically.', + }), + getOrderMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.orderMenuItemLabel', { + defaultMessage: 'Order', + description: 'Refers to the order of the elements displayed on the page from front to back', + }), + getRedoMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.redoMenuItemLabel', { + defaultMessage: 'Redo', + }), + getRightAlignMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.rightAlignMenuItemLabel', { + defaultMessage: 'Right', + }), + getSaveElementMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.savedElementMenuItemLabel', { + defaultMessage: 'Save as new element', + }), + getTopAlignMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.topAlignMenuItemLabel', { + defaultMessage: 'Top', + }), + getUndoMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.undoMenuItemLabel', { + defaultMessage: 'Undo', + }), + getUngroupMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.ungroupMenuItemLabel', { + defaultMessage: 'Ungroup', + description: 'This refers to ungrouping a grouped element', + }), + getVerticalDistributionMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderEditMenu.verticalDistributionMenutItemLabel', { + defaultMessage: 'Vertical', + }), + }, WorkpadHeaderElementMenu: { getAssetsMenuItemLabel: () => i18n.translate('xpack.canvas.workpadHeaderElementMenu.manageAssetsMenuItemLabel', { @@ -1305,6 +1313,18 @@ export const ComponentStrings = { }), }, WorkpadHeaderViewMenu: { + getAutoplayOffMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderViewMenu.autoplayOffMenuItemLabel', { + defaultMessage: 'Turn autoplay off', + }), + getAutoplayOnMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderViewMenu.autoplayOnMenuItemLabel', { + defaultMessage: 'Turn autoplay on', + }), + getAutoplaySettingsMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderViewMenu.autoplaySettingsMenuItemLabel', { + defaultMessage: 'Autoplay settings', + }), getFullscreenMenuItemLabel: () => i18n.translate('xpack.canvas.workpadHeaderViewMenu.fullscreenMenuLabel', { defaultMessage: 'Enter fullscreen mode', @@ -1317,6 +1337,10 @@ export const ComponentStrings = { i18n.translate('xpack.canvas.workpadHeaderViewMenu.refreshMenuItemLabel', { defaultMessage: 'Refresh data', }), + getRefreshSettingsMenuItemLabel: () => + i18n.translate('xpack.canvas.workpadHeaderViewMenu.refreshSettingsMenuItemLabel', { + defaultMessage: 'Auto refresh settings', + }), getShowEditModeLabel: () => i18n.translate('xpack.canvas.workpadHeaderViewMenu.showEditModeLabel', { defaultMessage: 'Show editing controls', diff --git a/x-pack/legacy/plugins/canvas/i18n/shortcuts.ts b/x-pack/legacy/plugins/canvas/i18n/shortcuts.ts index 32b07a45c17db..124d70ff3095f 100644 --- a/x-pack/legacy/plugins/canvas/i18n/shortcuts.ts +++ b/x-pack/legacy/plugins/canvas/i18n/shortcuts.ts @@ -42,10 +42,10 @@ export const ShortcutStrings = { defaultMessage: 'Delete', }), BRING_FORWARD: i18n.translate('xpack.canvas.keyboardShortcuts.bringFowardShortcutHelpText', { - defaultMessage: 'Bring to front', + defaultMessage: 'Bring forward', }), BRING_TO_FRONT: i18n.translate('xpack.canvas.keyboardShortcuts.bringToFrontShortcutHelpText', { - defaultMessage: 'Bring forward', + defaultMessage: 'Bring to front', }), SEND_BACKWARD: i18n.translate('xpack.canvas.keyboardShortcuts.sendBackwardShortcutHelpText', { defaultMessage: 'Send backward', diff --git a/x-pack/legacy/plugins/canvas/index.js b/x-pack/legacy/plugins/canvas/index.js index a1d4b35826b00..4c7825e5b58aa 100644 --- a/x-pack/legacy/plugins/canvas/index.js +++ b/x-pack/legacy/plugins/canvas/index.js @@ -6,14 +6,13 @@ import { resolve } from 'path'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; -import { init } from './init'; import { CANVAS_APP, CANVAS_TYPE, CUSTOM_ELEMENT_TYPE } from './common/lib'; export function canvas(kibana) { return new kibana.Plugin({ id: CANVAS_APP, configPrefix: 'xpack.canvas', - require: ['kibana', 'elasticsearch', 'xpack_main', 'interpreter'], + require: ['kibana', 'elasticsearch', 'xpack_main'], publicDir: resolve(__dirname, 'public'), uiExports: { app: { @@ -64,6 +63,6 @@ export function canvas(kibana) { }).default(); }, - init, + init: () => undefined, }); } diff --git a/x-pack/legacy/plugins/canvas/init.ts b/x-pack/legacy/plugins/canvas/init.ts deleted file mode 100644 index 89b940a638476..0000000000000 --- a/x-pack/legacy/plugins/canvas/init.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -import { Plugin } from './server/plugin'; -import { createSetupShim } from './server/shim'; - -export const init = async function(server: Legacy.Server) { - const { coreSetup, pluginsSetup } = await createSetupShim(server); - const serverPlugin = new Plugin(); - - serverPlugin.setup(coreSetup, pluginsSetup); -}; diff --git a/x-pack/legacy/plugins/canvas/public/application.tsx b/x-pack/legacy/plugins/canvas/public/application.tsx index f746a24e9b261..8ee65c3386afc 100644 --- a/x-pack/legacy/plugins/canvas/public/application.tsx +++ b/x-pack/legacy/plugins/canvas/public/application.tsx @@ -24,7 +24,7 @@ import { initRegistries, populateRegistries, destroyRegistries } from './registr import { getDocumentationLinks } from './lib/documentation_links'; // @ts-ignore untyped component import { HelpMenu } from './components/help_menu/help_menu'; -import { createStore } from './store'; +import { createStore, destroyStore } from './store'; import { VALUE_CLICK_TRIGGER, ActionByType } from '../../../../../src/plugins/ui_actions/public'; /* eslint-disable */ @@ -35,6 +35,12 @@ import { init as initStatsReporter } from './lib/ui_metric'; import { CapabilitiesStrings } from '../i18n'; import { startServices, stopServices, services } from './services'; +// @ts-ignore Untyped local +import { destroyHistory } from './lib/history_provider'; +// @ts-ignore Untyped local +import { stopRouter } from './lib/router_provider'; + +import './style/index.scss'; const { ReadOnlyBadge: strings } = CapabilitiesStrings; @@ -54,6 +60,8 @@ export const renderApp = ( { element }: AppMountParameters, canvasStore: Store ) => { + element.classList.add('canvas'); + element.classList.add('canvasContainerWrapper'); const canvasServices = Object.entries(services).reduce((reduction, [key, provider]) => { reduction[key] = provider.getService(); @@ -70,7 +78,9 @@ export const renderApp = ( </KibanaContextProvider>, element ); - return () => ReactDOM.unmountComponentAtNode(element); + return () => { + ReactDOM.unmountComponentAtNode(element); + }; }; export const initializeCanvas = async ( @@ -130,7 +140,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) { @@ -144,13 +154,17 @@ export const teardownCanvas = (coreStart: CoreStart, startPlugins: CanvasStartDe stopServices(); destroyRegistries(); resetInterpreter(); + destroyStore(); 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; } coreStart.chrome.setBadge(undefined); coreStart.chrome.setHelpExtension(undefined); + + destroyHistory(); + stopRouter(); }; diff --git a/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/__snapshots__/export_app.test.tsx.snap b/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/__snapshots__/export_app.test.tsx.snap new file mode 100644 index 0000000000000..19e9000c3bffc --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/__snapshots__/export_app.test.tsx.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`<ExportApp /> renders as expected 1`] = ` +<ExportApp + initializeWorkpad={[Function]} + selectedPageIndex={0} + workpad={ + Object { + "css": "", + "id": "my-workpad-abcd", + "pages": Array [ + Object { + "elements": Array [ + 0, + 1, + 2, + ], + }, + Object { + "elements": Array [ + 3, + 4, + 5, + 6, + ], + }, + ], + } + } +> + <div + className="canvasExport" + data-shared-page={1} + > + <div + className="canvasExport__stage" + > + <div + className="canvasLayout__stageHeader" + > + <Link + name="loadWorkpad" + params={ + Object { + "id": "my-workpad-abcd", + } + } + > + <div> + Link + </div> + </Link> + </div> + <div + className="canvasExport__stageContent" + data-shared-items-count={3} + > + <WorkpadPage + isSelected={true} + registerLayout={[Function]} + unregisterLayout={[Function]} + > + <div> + Page + </div> + </WorkpadPage> + </div> + </div> + </div> +</ExportApp> +`; + +exports[`<ExportApp /> renders as expected 2`] = ` +<ExportApp + initializeWorkpad={[Function]} + selectedPageIndex={1} + workpad={ + Object { + "css": "", + "id": "my-workpad-abcd", + "pages": Array [ + Object { + "elements": Array [ + 0, + 1, + 2, + ], + }, + Object { + "elements": Array [ + 3, + 4, + 5, + 6, + ], + }, + ], + } + } +> + <div + className="canvasExport" + data-shared-page={2} + > + <div + className="canvasExport__stage" + > + <div + className="canvasLayout__stageHeader" + > + <Link + name="loadWorkpad" + params={ + Object { + "id": "my-workpad-abcd", + } + } + > + <div> + Link + </div> + </Link> + </div> + <div + className="canvasExport__stageContent" + data-shared-items-count={4} + > + <WorkpadPage + isSelected={true} + registerLayout={[Function]} + unregisterLayout={[Function]} + > + <div> + Page + </div> + </WorkpadPage> + </div> + </div> + </div> +</ExportApp> +`; diff --git a/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx b/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx new file mode 100644 index 0000000000000..7f5b53df4ba52 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +// @ts-ignore untyped local +import { ExportApp } from '../export_app'; + +jest.mock('style-it', () => ({ + it: (css: string, Component: any) => Component, +})); + +jest.mock('../../../../components/workpad_page', () => ({ + WorkpadPage: (props: any) => <div>Page</div>, +})); + +jest.mock('../../../../components/link', () => ({ + Link: (props: any) => <div>Link</div>, +})); + +describe('<ExportApp />', () => { + test('renders as expected', () => { + const sampleWorkpad = { + id: 'my-workpad-abcd', + css: '', + pages: [ + { + elements: [0, 1, 2], + }, + { + elements: [3, 4, 5, 6], + }, + ], + }; + + const page1 = mount( + <ExportApp workpad={sampleWorkpad} selectedPageIndex={0} initializeWorkpad={() => {}} /> + ); + expect(page1).toMatchSnapshot(); + + const page2 = mount( + <ExportApp workpad={sampleWorkpad} selectedPageIndex={1} initializeWorkpad={() => {}} /> + ); + expect(page2).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/public/apps/export/export/export_app.js b/x-pack/legacy/plugins/canvas/public/apps/export/export/export_app.js index 7537f8eaa9039..1d02d85cae0b3 100644 --- a/x-pack/legacy/plugins/canvas/public/apps/export/export/export_app.js +++ b/x-pack/legacy/plugins/canvas/public/apps/export/export/export_app.js @@ -16,7 +16,7 @@ export class ExportApp extends React.PureComponent { id: PropTypes.string.isRequired, pages: PropTypes.array.isRequired, }).isRequired, - selectedPageId: PropTypes.string.isRequired, + selectedPageIndex: PropTypes.number.isRequired, initializeWorkpad: PropTypes.func.isRequired, }; @@ -25,13 +25,13 @@ export class ExportApp extends React.PureComponent { } render() { - const { workpad, selectedPageId } = this.props; + const { workpad, selectedPageIndex } = this.props; const { pages, height, width } = workpad; - const activePage = pages.find(page => page.id === selectedPageId); + const activePage = pages[selectedPageIndex]; const pageElementCount = activePage.elements.length; return ( - <div className="canvasExport"> + <div className="canvasExport" data-shared-page={selectedPageIndex + 1}> <div className="canvasExport__stage"> <div className="canvasLayout__stageHeader"> <Link name="loadWorkpad" params={{ id: this.props.workpad.id }}> diff --git a/x-pack/legacy/plugins/canvas/public/apps/export/export/index.js b/x-pack/legacy/plugins/canvas/public/apps/export/export/index.js index d40c5f787e44f..dafcb9f4c2510 100644 --- a/x-pack/legacy/plugins/canvas/public/apps/export/export/index.js +++ b/x-pack/legacy/plugins/canvas/public/apps/export/export/index.js @@ -7,13 +7,13 @@ import { connect } from 'react-redux'; import { compose, branch, renderComponent } from 'recompose'; import { initializeWorkpad } from '../../../state/actions/workpad'; -import { getWorkpad, getSelectedPage } from '../../../state/selectors/workpad'; +import { getWorkpad, getSelectedPageIndex } from '../../../state/selectors/workpad'; import { LoadWorkpad } from './load_workpad'; import { ExportApp as Component } from './export_app'; const mapStateToProps = state => ({ workpad: getWorkpad(state), - selectedPageId: getSelectedPage(state), + selectedPageIndex: getSelectedPageIndex(state), }); const mapDispatchToProps = dispatch => ({ diff --git a/x-pack/legacy/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.js b/x-pack/legacy/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.js index b8e1bece6ac30..fc3ac9922355a 100644 --- a/x-pack/legacy/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.js +++ b/x-pack/legacy/plugins/canvas/public/apps/workpad/workpad_app/workpad_app.js @@ -43,7 +43,7 @@ export class WorkpadApp extends React.PureComponent { <div className="canvasLayout__cols"> <div className="canvasLayout__stage"> <div className="canvasLayout__stageHeader"> - <WorkpadHeader /> + <WorkpadHeader commit={this.interactivePageLayout || (() => {})} /> </div> <div @@ -66,7 +66,7 @@ export class WorkpadApp extends React.PureComponent { {isWriteable && ( <div className="canvasLayout__sidebar hide-for-sharing"> - <Sidebar commit={this.interactivePageLayout || (() => {})} /> + <Sidebar /> </div> )} </div> diff --git a/x-pack/legacy/plugins/canvas/public/components/app/index.js b/x-pack/legacy/plugins/canvas/public/components/app/index.js index 36af5477631b4..de0d4c190eae6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/app/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/app/index.js @@ -10,7 +10,6 @@ import { getAppReady, getBasePath } from '../../state/selectors/app'; import { appReady, appError } from '../../state/actions/app'; import { App as Component } from './app'; -import { trackRouteChange } from './track_route_change'; const mapStateToProps = state => { // appReady could be an error object @@ -46,6 +45,6 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { export const App = compose( connect(mapStateToProps, mapDispatchToProps, mergeProps), withProps(() => ({ - onRouteChange: trackRouteChange, + onRouteChange: () => undefined, })) )(Component); diff --git a/x-pack/legacy/plugins/canvas/public/components/keyboard_shortcuts_doc/__examples__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot b/x-pack/legacy/plugins/canvas/public/components/keyboard_shortcuts_doc/__examples__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot index 503677687ba12..d59d03578a363 100644 --- a/x-pack/legacy/plugins/canvas/public/components/keyboard_shortcuts_doc/__examples__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot +++ b/x-pack/legacy/plugins/canvas/public/components/keyboard_shortcuts_doc/__examples__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot @@ -212,7 +212,7 @@ exports[`Storyshots components/KeyboardShortcutsDoc default 1`] = ` <dt className="euiDescriptionList__title" > - Bring forward + Bring to front </dt> <dd className="euiDescriptionList__description" @@ -234,7 +234,7 @@ exports[`Storyshots components/KeyboardShortcutsDoc default 1`] = ` <dt className="euiDescriptionList__title" > - Bring to front + Bring forward </dt> <dd className="euiDescriptionList__description" diff --git a/x-pack/legacy/plugins/canvas/public/components/sidebar/sidebar_content.js b/x-pack/legacy/plugins/canvas/public/components/sidebar/sidebar_content.js index 9de5f81b440ba..f333705a1a3c6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/sidebar/sidebar_content.js +++ b/x-pack/legacy/plugins/canvas/public/components/sidebar/sidebar_content.js @@ -10,7 +10,6 @@ import { compose, branch, renderComponent } from 'recompose'; import { EuiSpacer } from '@elastic/eui'; import { getSelectedToplevelNodes, getSelectedElementId } from '../../state/selectors/workpad'; import { SidebarHeader } from '../sidebar_header'; -import { globalStateUpdater } from '../workpad_page/integration_utils'; import { ComponentStrings } from '../../../i18n'; import { MultiElementSettings } from './multi_element_settings'; import { GroupSettings } from './group_settings'; @@ -22,45 +21,19 @@ const { SidebarContent: strings } = ComponentStrings; const mapStateToProps = state => ({ selectedToplevelNodes: getSelectedToplevelNodes(state), selectedElementId: getSelectedElementId(state), - state, }); -const mergeProps = ( - { state, ...restStateProps }, - { dispatch, ...restDispatchProps }, - ownProps -) => ({ - ...ownProps, - ...restDispatchProps, - ...restStateProps, - updateGlobalState: globalStateUpdater(dispatch, state), -}); - -const withGlobalState = (commit, updateGlobalState) => (type, payload) => { - const newLayoutState = commit(type, payload); - if (newLayoutState.currentScene.gestureEnd) { - updateGlobalState(newLayoutState); - } -}; - -const MultiElementSidebar = ({ commit, updateGlobalState }) => ( +const MultiElementSidebar = () => ( <Fragment> - <SidebarHeader - title={strings.getMultiElementSidebarTitle()} - commit={withGlobalState(commit, updateGlobalState)} - /> + <SidebarHeader title={strings.getMultiElementSidebarTitle()} /> <EuiSpacer /> <MultiElementSettings /> </Fragment> ); -const GroupedElementSidebar = ({ commit, updateGlobalState }) => ( +const GroupedElementSidebar = () => ( <Fragment> - <SidebarHeader - title={strings.getGroupedElementSidebarTitle()} - commit={withGlobalState(commit, updateGlobalState)} - groupIsSelected - /> + <SidebarHeader title={strings.getGroupedElementSidebarTitle()} groupIsSelected /> <EuiSpacer /> <GroupSettings /> </Fragment> @@ -92,7 +65,4 @@ const branches = [ ), ]; -export const SidebarContent = compose( - connect(mapStateToProps, null, mergeProps), - ...branches -)(GlobalConfig); +export const SidebarContent = compose(connect(mapStateToProps), ...branches)(GlobalConfig); diff --git a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot index ac25cbe0b6832..4d5b9570ee20f 100644 --- a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot +++ b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/__snapshots__/sidebar_header.stories.storyshot @@ -20,80 +20,6 @@ exports[`Storyshots components/Sidebar/SidebarHeader default 1`] = ` Selected layer </h3> </div> - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - > - <div - className="euiFlexGroup euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive" - > - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - > - <span - className="euiToolTipAnchor" - onMouseOut={[Function]} - onMouseOver={[Function]} - > - <button - aria-label="Save as new element" - className="euiButtonIcon euiButtonIcon--text" - data-test-subj="canvasSidebarHeader__saveElementButton" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - type="button" - > - <div - aria-hidden="true" - className="euiButtonIcon__icon" - data-euiicon-type="indexOpen" - size="m" - /> - </button> - </span> - </div> - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - > - <div - className="euiPopover euiPopover--anchorDownCenter canvasContextMenu" - container={null} - id="sidebar-context-menu-popover" - onKeyDown={[Function]} - onMouseDown={[Function]} - onMouseUp={[Function]} - onTouchEnd={[Function]} - onTouchStart={[Function]} - > - <div - className="euiPopover__anchor" - > - <span - className="euiToolTipAnchor" - onMouseOut={[Function]} - onMouseOver={[Function]} - > - <button - aria-label="Element options" - className="euiButtonIcon euiButtonIcon--text" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - type="button" - > - <div - aria-hidden="true" - className="euiButtonIcon__icon" - data-euiicon-type="boxesVertical" - size="m" - /> - </button> - </span> - </div> - </div> - </div> - </div> - </div> </div> </div> `; @@ -224,72 +150,6 @@ exports[`Storyshots components/Sidebar/SidebarHeader with layer controls 1`] = ` </button> </span> </div> - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - > - <span - className="euiToolTipAnchor" - onMouseOut={[Function]} - onMouseOver={[Function]} - > - <button - aria-label="Save as new element" - className="euiButtonIcon euiButtonIcon--text" - data-test-subj="canvasSidebarHeader__saveElementButton" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - type="button" - > - <div - aria-hidden="true" - className="euiButtonIcon__icon" - data-euiicon-type="indexOpen" - size="m" - /> - </button> - </span> - </div> - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - > - <div - className="euiPopover euiPopover--anchorDownCenter canvasContextMenu" - container={null} - id="sidebar-context-menu-popover" - onKeyDown={[Function]} - onMouseDown={[Function]} - onMouseUp={[Function]} - onTouchEnd={[Function]} - onTouchStart={[Function]} - > - <div - className="euiPopover__anchor" - > - <span - className="euiToolTipAnchor" - onMouseOut={[Function]} - onMouseOver={[Function]} - > - <button - aria-label="Element options" - className="euiButtonIcon euiButtonIcon--text" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - type="button" - > - <div - aria-hidden="true" - className="euiButtonIcon__icon" - data-euiicon-type="boxesVertical" - size="m" - /> - </button> - </span> - </div> - </div> - </div> </div> </div> </div> diff --git a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.stories.tsx b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.stories.tsx index 9baff380fc3eb..11c66906a6ef6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.stories.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.stories.tsx @@ -10,26 +10,10 @@ import { action } from '@storybook/addon-actions'; import { SidebarHeader } from '../sidebar_header'; const handlers = { - cloneNodes: action('cloneNodes'), - copyNodes: action('copyNodes'), - cutNodes: action('cutNodes'), - pasteNodes: action('pasteNodes'), - deleteNodes: action('deleteNodes'), bringToFront: action('bringToFront'), bringForward: action('bringForward'), sendBackward: action('sendBackward'), sendToBack: action('sendToBack'), - createCustomElement: action('createCustomElement'), - groupNodes: action('groupNodes'), - ungroupNodes: action('ungroupNodes'), - alignLeft: action('alignLeft'), - alignMiddle: action('alignMiddle'), - alignRight: action('alignRight'), - alignTop: action('alignTop'), - alignCenter: action('alignCenter'), - alignBottom: action('alignBottom'), - distributeHorizontally: action('distributeHorizontally'), - distributeVertically: action('distributeVertically'), }; storiesOf('components/Sidebar/SidebarHeader', module) diff --git a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx index 925e68a565f04..024a2dbb41a24 100644 --- a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx @@ -4,25 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; +import React, { FunctionComponent } from 'react'; import PropTypes from 'prop-types'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiButtonIcon, - EuiContextMenu, - EuiToolTip, - EuiContextMenuPanelItemDescriptor, - EuiContextMenuPanelDescriptor, - EuiOverlayMask, -} from '@elastic/eui'; -import { Popover } from '../popover'; -import { CustomElementModal } from '../custom_element_modal'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { ToolTipShortcut } from '../tool_tip_shortcut/'; import { ComponentStrings } from '../../../i18n/components'; import { ShortcutStrings } from '../../../i18n/shortcuts'; -import { CONTEXT_MENU_TOP_BORDER_CLASSNAME } from '../../../common/lib/constants'; const { SidebarHeader: strings } = ComponentStrings; const shortcutHelp = ShortcutStrings.getShortcutHelp(); @@ -36,26 +23,6 @@ interface Props { * indicated whether or not layer controls should be displayed */ showLayerControls?: boolean; - /** - * cuts selected elements - */ - cutNodes: () => void; - /** - * copies selected elements to clipboard - */ - copyNodes: () => void; - /** - * pastes elements stored in clipboard to page - */ - pasteNodes: () => void; - /** - * clones selected elements - */ - cloneNodes: () => void; - /** - * deletes selected elements - */ - deleteNodes: () => void; /** * moves selected element to top layer */ @@ -72,493 +39,117 @@ interface Props { * moves selected element to bottom layer */ sendToBack: () => void; - /** - * saves the selected elements as an custom-element saved object - */ - createCustomElement: (name: string, description: string, image: string) => void; - /** - * indicated whether the selected element is a group or not - */ - groupIsSelected: boolean; - /** - * only more than one selected element can be grouped - */ - selectedNodes: string[]; - /** - * groups selected elements - */ - groupNodes: () => void; - /** - * ungroups selected group - */ - ungroupNodes: () => void; - /** - * left align selected elements - */ - alignLeft: () => void; - /** - * center align selected elements - */ - alignCenter: () => void; - /** - * right align selected elements - */ - alignRight: () => void; - /** - * top align selected elements - */ - alignTop: () => void; - /** - * middle align selected elements - */ - alignMiddle: () => void; - /** - * bottom align selected elements - */ - alignBottom: () => void; - /** - * horizontally distribute selected elements - */ - distributeHorizontally: () => void; - /** - * vertically distribute selected elements - */ - distributeVertically: () => void; -} - -interface State { - /** - * indicates whether or not the custom element modal is open - */ - isModalVisible: boolean; -} - -interface MenuTuple { - menuItem: EuiContextMenuPanelItemDescriptor; - panel: EuiContextMenuPanelDescriptor; } -const contextMenuButton = (handleClick: React.MouseEventHandler<HTMLButtonElement>) => ( - <EuiButtonIcon - color="text" - iconType="boxesVertical" - onClick={handleClick} - aria-label={strings.getContextMenuTitle()} - /> -); - -export class SidebarHeader extends Component<Props, State> { - public static propTypes = { - title: PropTypes.string.isRequired, - showLayerControls: PropTypes.bool, // TODO: remove when we support relayering multiple elements - cutNodes: PropTypes.func.isRequired, - copyNodes: PropTypes.func.isRequired, - pasteNodes: PropTypes.func.isRequired, - cloneNodes: PropTypes.func.isRequired, - deleteNodes: PropTypes.func.isRequired, - bringToFront: PropTypes.func.isRequired, - bringForward: PropTypes.func.isRequired, - sendBackward: PropTypes.func.isRequired, - sendToBack: PropTypes.func.isRequired, - createCustomElement: PropTypes.func.isRequired, - groupIsSelected: PropTypes.bool, - selectedNodes: PropTypes.array, - groupNodes: PropTypes.func.isRequired, - ungroupNodes: PropTypes.func.isRequired, - alignLeft: PropTypes.func.isRequired, - alignCenter: PropTypes.func.isRequired, - alignRight: PropTypes.func.isRequired, - alignTop: PropTypes.func.isRequired, - alignMiddle: PropTypes.func.isRequired, - alignBottom: PropTypes.func.isRequired, - distributeHorizontally: PropTypes.func.isRequired, - distributeVertically: PropTypes.func.isRequired, - }; - - public static defaultProps = { - groupIsSelected: false, - showLayerControls: false, - selectedNodes: [], - }; - - public state = { - isModalVisible: false, - }; - - private _isMounted = false; - private _showModal = () => this._isMounted && this.setState({ isModalVisible: true }); - private _hideModal = () => this._isMounted && this.setState({ isModalVisible: false }); - - public componentDidMount() { - this._isMounted = true; - } - - public componentWillUnmount() { - this._isMounted = false; - } - - private _renderLayoutControls = () => { - const { bringToFront, bringForward, sendBackward, sendToBack } = this.props; - return ( - <Fragment> - <EuiFlexItem grow={false}> - <EuiToolTip - position="bottom" - content={ - <span> - {shortcutHelp.BRING_TO_FRONT} - <ToolTipShortcut namespace="ELEMENT" action="BRING_TO_FRONT" /> - </span> - } - > - <EuiButtonIcon - color="text" - iconType="sortUp" - onClick={bringToFront} - aria-label={strings.getBringToFrontAriaLabel()} - /> - </EuiToolTip> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiToolTip - position="bottom" - content={ - <span> - {shortcutHelp.BRING_FORWARD} - <ToolTipShortcut namespace="ELEMENT" action="BRING_FORWARD" /> - </span> - } - > - <EuiButtonIcon - color="text" - iconType="arrowUp" - onClick={bringForward} - aria-label={strings.getBringForwardAriaLabel()} - /> - </EuiToolTip> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiToolTip - position="bottom" - content={ - <span> - {shortcutHelp.SEND_BACKWARD} - <ToolTipShortcut namespace="ELEMENT" action="SEND_BACKWARD" /> - </span> - } - > - <EuiButtonIcon - color="text" - iconType="arrowDown" - onClick={sendBackward} - aria-label={strings.getSendBackwardAriaLabel()} - /> - </EuiToolTip> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiToolTip - position="bottom" - content={ - <span> - {shortcutHelp.SEND_TO_BACK} - <ToolTipShortcut namespace="ELEMENT" action="SEND_TO_BACK" /> - </span> - } - > - <EuiButtonIcon - color="text" - iconType="sortDown" - onClick={sendToBack} - aria-label={strings.getSendToBackAriaLabel()} - /> - </EuiToolTip> - </EuiFlexItem> - </Fragment> - ); - }; - - private _getLayerMenuItems = (): MenuTuple => { - const { bringToFront, bringForward, sendBackward, sendToBack } = this.props; - - return { - menuItem: { - name: strings.getOrderMenuItemLabel(), - className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, - panel: 1, - }, - panel: { - id: 1, - title: strings.getOrderMenuItemLabel(), - items: [ - { - name: shortcutHelp.BRING_TO_FRONT, // TODO: check against current element position and disable if already top layer - icon: 'sortUp', - onClick: bringToFront, - }, - { - name: shortcutHelp.BRING_TO_FRONT, // TODO: same as above - icon: 'arrowUp', - onClick: bringForward, - }, - { - name: shortcutHelp.SEND_BACKWARD, // TODO: check against current element position and disable if already bottom layer - icon: 'arrowDown', - onClick: sendBackward, - }, - { - name: shortcutHelp.SEND_TO_BACK, // TODO: same as above - icon: 'sortDown', - onClick: sendToBack, - }, - ], - }, - }; - }; - - private _getAlignmentMenuItems = (close: (fn: () => void) => () => void): MenuTuple => { - const { alignLeft, alignCenter, alignRight, alignTop, alignMiddle, alignBottom } = this.props; - - return { - menuItem: { - name: strings.getAlignmentMenuItemLabel(), - className: 'canvasContextMenu', - panel: 2, - }, - panel: { - id: 2, - title: strings.getAlignmentMenuItemLabel(), - items: [ - { - name: strings.getLeftAlignMenuItemLabel(), - icon: 'editorItemAlignLeft', - onClick: close(alignLeft), - }, - { - name: strings.getCenterAlignMenuItemLabel(), - icon: 'editorItemAlignCenter', - onClick: close(alignCenter), - }, - { - name: strings.getRightAlignMenuItemLabel(), - icon: 'editorItemAlignRight', - onClick: close(alignRight), - }, - { - name: strings.getTopAlignMenuItemLabel(), - icon: 'editorItemAlignTop', - onClick: close(alignTop), - }, - { - name: strings.getMiddleAlignMenuItemLabel(), - icon: 'editorItemAlignMiddle', - onClick: close(alignMiddle), - }, - { - name: strings.getBottomAlignMenuItemLabel(), - icon: 'editorItemAlignBottom', - onClick: close(alignBottom), - }, - ], - }, - }; - }; - - private _getDistributionMenuItems = (close: (fn: () => void) => () => void): MenuTuple => { - const { distributeHorizontally, distributeVertically } = this.props; - - return { - menuItem: { - name: strings.getDistributionMenuItemLabel(), - className: 'canvasContextMenu', - panel: 3, - }, - panel: { - id: 3, - title: strings.getDistributionMenuItemLabel(), - items: [ - { - name: strings.getHorizontalDistributionMenuItemLabel(), - icon: 'editorDistributeHorizontal', - onClick: close(distributeHorizontally), - }, - { - name: strings.getVerticalDistributionMenuItemLabel(), - icon: 'editorDistributeVertical', - onClick: close(distributeVertically), - }, - ], - }, - }; - }; - - private _getGroupMenuItems = ( - close: (fn: () => void) => () => void - ): EuiContextMenuPanelItemDescriptor[] => { - const { groupIsSelected, ungroupNodes, groupNodes, selectedNodes } = this.props; - return groupIsSelected - ? [ - { - name: strings.getUngroupMenuItemLabel(), - className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, - onClick: close(ungroupNodes), - }, - ] - : selectedNodes.length > 1 - ? [ - { - name: strings.getGroupMenuItemLabel(), - className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, - onClick: close(groupNodes), - }, - ] - : []; - }; - - private _getPanels = (closePopover: () => void): EuiContextMenuPanelDescriptor[] => { - const { - showLayerControls, - cutNodes, - copyNodes, - pasteNodes, - deleteNodes, - cloneNodes, - } = this.props; - - // closes popover after invoking fn - const close = (fn: () => void) => () => { - fn(); - closePopover(); - }; - - const items: EuiContextMenuPanelItemDescriptor[] = [ - { - name: shortcutHelp.CUT, - icon: 'cut', - onClick: close(cutNodes), - }, - { - name: shortcutHelp.COPY, - icon: 'copy', - onClick: copyNodes, - }, - { - name: shortcutHelp.PASTE, // TODO: can this be disabled if clipboard is empty? - icon: 'copyClipboard', - onClick: close(pasteNodes), - }, - { - name: shortcutHelp.DELETE, - icon: 'trash', - onClick: close(deleteNodes), - }, - { - name: shortcutHelp.CLONE, - onClick: close(cloneNodes), - }, - ...this._getGroupMenuItems(close), - ]; - - const panels: EuiContextMenuPanelDescriptor[] = [ - { - id: 0, - title: strings.getContextMenuTitle(), - items, - }, - ]; - - const fillMenu = ({ menuItem, panel }: MenuTuple) => { - items.push(menuItem); // add Order menu item to first panel - panels.push(panel); // add nested panel for layers controls - }; - - if (showLayerControls) { - fillMenu(this._getLayerMenuItems()); - } - - if (this.props.selectedNodes.length > 1) { - fillMenu(this._getAlignmentMenuItems(close)); - } - - if (this.props.selectedNodes.length > 2) { - fillMenu(this._getDistributionMenuItems(close)); - } - - items.push({ - name: strings.getSaveElementMenuItemLabel(), - icon: 'indexOpen', - className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, - onClick: this._showModal, - }); - - return panels; - }; - - private _renderContextMenu = () => ( - <Popover - id="sidebar-context-menu-popover" - className="canvasContextMenu" - button={contextMenuButton} - panelPaddingSize="none" - tooltip={strings.getContextMenuTitle()} - tooltipPosition="bottom" - > - {({ closePopover }: { closePopover: () => void }) => ( - <EuiContextMenu initialPanelId={0} panels={this._getPanels(closePopover)} /> - )} - </Popover> - ); - - private _handleSave = (name: string, description: string, image: string) => { - const { createCustomElement } = this.props; - createCustomElement(name, description, image); - this._hideModal(); - }; - - render() { - const { title, showLayerControls } = this.props; - const { isModalVisible } = this.state; - - return ( - <Fragment> - <EuiFlexGroup - className="canvasLayout__sidebarHeader" - gutterSize="none" - alignItems="center" - justifyContent="spaceBetween" - > +export const SidebarHeader: FunctionComponent<Props> = ({ + title, + showLayerControls, + bringToFront, + bringForward, + sendBackward, + sendToBack, +}) => ( + <EuiFlexGroup + className="canvasLayout__sidebarHeader" + gutterSize="none" + alignItems="center" + justifyContent="spaceBetween" + > + <EuiFlexItem grow={false}> + <EuiTitle size="xs"> + <h3>{title}</h3> + </EuiTitle> + </EuiFlexItem> + {showLayerControls ? ( + <EuiFlexItem grow={false}> + <EuiFlexGroup alignItems="center" gutterSize="none"> <EuiFlexItem grow={false}> - <EuiTitle size="xs"> - <h3>{title}</h3> - </EuiTitle> + <EuiToolTip + position="bottom" + content={ + <span> + {shortcutHelp.BRING_TO_FRONT} + <ToolTipShortcut namespace="ELEMENT" action="BRING_TO_FRONT" /> + </span> + } + > + <EuiButtonIcon + color="text" + iconType="sortUp" + onClick={bringToFront} + aria-label={strings.getBringToFrontAriaLabel()} + /> + </EuiToolTip> </EuiFlexItem> <EuiFlexItem grow={false}> - <EuiFlexGroup alignItems="center" gutterSize="none"> - {showLayerControls ? this._renderLayoutControls() : null} - <EuiFlexItem grow={false}> - <EuiToolTip position="bottom" content={strings.getSaveElementMenuItemLabel()}> - <EuiButtonIcon - color="text" - iconType="indexOpen" - onClick={this._showModal} - data-test-subj="canvasSidebarHeader__saveElementButton" - aria-label={strings.getSaveElementMenuItemLabel()} - /> - </EuiToolTip> - </EuiFlexItem> - <EuiFlexItem grow={false}>{this._renderContextMenu()}</EuiFlexItem> - </EuiFlexGroup> + <EuiToolTip + position="bottom" + content={ + <span> + {shortcutHelp.BRING_FORWARD} + <ToolTipShortcut namespace="ELEMENT" action="BRING_FORWARD" /> + </span> + } + > + <EuiButtonIcon + color="text" + iconType="arrowUp" + onClick={bringForward} + aria-label={strings.getBringForwardAriaLabel()} + /> + </EuiToolTip> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiToolTip + position="bottom" + content={ + <span> + {shortcutHelp.SEND_BACKWARD} + <ToolTipShortcut namespace="ELEMENT" action="SEND_BACKWARD" /> + </span> + } + > + <EuiButtonIcon + color="text" + iconType="arrowDown" + onClick={sendBackward} + aria-label={strings.getSendBackwardAriaLabel()} + /> + </EuiToolTip> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiToolTip + position="bottom" + content={ + <span> + {shortcutHelp.SEND_TO_BACK} + <ToolTipShortcut namespace="ELEMENT" action="SEND_TO_BACK" /> + </span> + } + > + <EuiButtonIcon + color="text" + iconType="sortDown" + onClick={sendToBack} + aria-label={strings.getSendToBackAriaLabel()} + /> + </EuiToolTip> </EuiFlexItem> </EuiFlexGroup> - {isModalVisible ? ( - <EuiOverlayMask> - <CustomElementModal - title={strings.getCreateElementModalTitle()} - onSave={this._handleSave} - onCancel={this._hideModal} - /> - </EuiOverlayMask> - ) : null} - </Fragment> - ); - } -} + </EuiFlexItem> + ) : null} + </EuiFlexGroup> +); + +SidebarHeader.propTypes = { + title: PropTypes.string.isRequired, + showLayerControls: PropTypes.bool, // TODO: remove when we support relayering multiple elements + bringToFront: PropTypes.func.isRequired, + bringForward: PropTypes.func.isRequired, + sendBackward: PropTypes.func.isRequired, + sendToBack: PropTypes.func.isRequired, +}; + +SidebarHeader.defaultProps = { + showLayerControls: false, +}; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/control_settings.scss b/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/control_settings.scss deleted file mode 100644 index 3d217dd1fc180..0000000000000 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/control_settings.scss +++ /dev/null @@ -1,7 +0,0 @@ -.canvasControlSettings__popover { - width: 600px; -} - -.canvasControlSettings__list { - columns: 2; -} diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/control_settings.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/control_settings.tsx deleted file mode 100644 index adc57ff4f815a..0000000000000 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/control_settings.tsx +++ /dev/null @@ -1,84 +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, { MouseEventHandler } from 'react'; -import PropTypes from 'prop-types'; -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -// @ts-ignore untyped local -import { Popover } from '../../popover'; -import { AutoRefreshControls } from './auto_refresh_controls'; -import { KioskControls } from './kiosk_controls'; - -import { ComponentStrings } from '../../../../i18n'; -const { WorkpadHeaderControlSettings: strings } = ComponentStrings; - -interface Props { - refreshInterval: number; - setRefreshInterval: (interval: number | undefined) => void; - autoplayEnabled: boolean; - autoplayInterval: number; - enableAutoplay: (enable: boolean) => void; - setAutoplayInterval: (interval: number | undefined) => void; -} - -export const ControlSettings = ({ - setRefreshInterval, - refreshInterval, - autoplayEnabled, - autoplayInterval, - enableAutoplay, - setAutoplayInterval, -}: Props) => { - const setRefresh = (val: number | undefined) => setRefreshInterval(val); - - const disableInterval = () => { - setRefresh(0); - }; - - const popoverButton = (handleClick: MouseEventHandler<HTMLButtonElement>) => ( - <EuiButtonEmpty size="xs" aria-label={strings.getButtonLabel()} onClick={handleClick}> - {strings.getButtonLabel()} - </EuiButtonEmpty> - ); - - return ( - <Popover - id="auto-refresh-popover" - button={popoverButton} - anchorPosition="downLeft" - panelClassName="canvasControlSettings__popover" - > - {() => ( - <EuiFlexGroup> - <EuiFlexItem> - <AutoRefreshControls - refreshInterval={refreshInterval} - setRefresh={val => setRefresh(val)} - disableInterval={() => disableInterval()} - /> - </EuiFlexItem> - <EuiFlexItem> - <KioskControls - autoplayEnabled={autoplayEnabled} - autoplayInterval={autoplayInterval} - onSetInterval={setAutoplayInterval} - onSetEnabled={enableAutoplay} - /> - </EuiFlexItem> - </EuiFlexGroup> - )} - </Popover> - ); -}; - -ControlSettings.propTypes = { - refreshInterval: PropTypes.number, - setRefreshInterval: PropTypes.func.isRequired, - autoplayEnabled: PropTypes.bool, - autoplayInterval: PropTypes.number, - enableAutoplay: PropTypes.func.isRequired, - setAutoplayInterval: PropTypes.func.isRequired, -}; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/index.ts b/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/index.ts deleted file mode 100644 index 316a49c85c09d..0000000000000 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/index.ts +++ /dev/null @@ -1,35 +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 { connect } from 'react-redux'; -import { - setRefreshInterval, - enableAutoplay, - setAutoplayInterval, - // @ts-ignore untyped local -} from '../../../state/actions/workpad'; -// @ts-ignore untyped local -import { getRefreshInterval, getAutoplay } from '../../../state/selectors/workpad'; -import { State } from '../../../../types'; -import { ControlSettings as Component } from './control_settings'; - -const mapStateToProps = (state: State) => { - const { enabled, interval } = getAutoplay(state); - - return { - refreshInterval: getRefreshInterval(state), - autoplayEnabled: enabled, - autoplayInterval: interval, - }; -}; - -const mapDispatchToProps = { - setRefreshInterval, - enableAutoplay, - setAutoplayInterval, -}; - -export const ControlSettings = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/__snapshots__/edit_menu.examples.storyshot b/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/__snapshots__/edit_menu.examples.storyshot new file mode 100644 index 0000000000000..42c59d41dca62 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/__snapshots__/edit_menu.examples.storyshot @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/WorkpadHeader/EditMenu 2 elements selected 1`] = ` +<div + className="euiPopover euiPopover--anchorDownLeft" + container={null} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} +> + <div + className="euiPopover__anchor" + > + <button + aria-label="Edit options" + className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall" + data-test-subj="canvasWorkpadEditMenuButton" + onClick={[Function]} + type="button" + > + <span + className="euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + Edit + </span> + </span> + </button> + </div> +</div> +`; + +exports[`Storyshots components/WorkpadHeader/EditMenu 3+ elements selected 1`] = ` +<div + className="euiPopover euiPopover--anchorDownLeft" + container={null} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} +> + <div + className="euiPopover__anchor" + > + <button + aria-label="Edit options" + className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall" + data-test-subj="canvasWorkpadEditMenuButton" + onClick={[Function]} + type="button" + > + <span + className="euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + Edit + </span> + </span> + </button> + </div> +</div> +`; + +exports[`Storyshots components/WorkpadHeader/EditMenu clipboard data exists 1`] = ` +<div + className="euiPopover euiPopover--anchorDownLeft" + container={null} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} +> + <div + className="euiPopover__anchor" + > + <button + aria-label="Edit options" + className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall" + data-test-subj="canvasWorkpadEditMenuButton" + onClick={[Function]} + type="button" + > + <span + className="euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + Edit + </span> + </span> + </button> + </div> +</div> +`; + +exports[`Storyshots components/WorkpadHeader/EditMenu default 1`] = ` +<div + className="euiPopover euiPopover--anchorDownLeft" + container={null} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} +> + <div + className="euiPopover__anchor" + > + <button + aria-label="Edit options" + className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall" + data-test-subj="canvasWorkpadEditMenuButton" + onClick={[Function]} + type="button" + > + <span + className="euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + Edit + </span> + </span> + </button> + </div> +</div> +`; + +exports[`Storyshots components/WorkpadHeader/EditMenu single element selected 1`] = ` +<div + className="euiPopover euiPopover--anchorDownLeft" + container={null} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} +> + <div + className="euiPopover__anchor" + > + <button + aria-label="Edit options" + className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall" + data-test-subj="canvasWorkpadEditMenuButton" + onClick={[Function]} + type="button" + > + <span + className="euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + Edit + </span> + </span> + </button> + </div> +</div> +`; + +exports[`Storyshots components/WorkpadHeader/EditMenu single grouped element selected 1`] = ` +<div + className="euiPopover euiPopover--anchorDownLeft" + container={null} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} +> + <div + className="euiPopover__anchor" + > + <button + aria-label="Edit options" + className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall" + data-test-subj="canvasWorkpadEditMenuButton" + onClick={[Function]} + type="button" + > + <span + className="euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + Edit + </span> + </span> + </button> + </div> +</div> +`; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.examples.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.examples.tsx new file mode 100644 index 0000000000000..a0ab8d53485f5 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/__examples__/edit_menu.examples.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 { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; +import { EditMenu } from '../edit_menu'; + +const handlers = { + cutNodes: action('cutNodes'), + copyNodes: action('copyNodes'), + pasteNodes: action('pasteNodes'), + deleteNodes: action('deleteNodes'), + cloneNodes: action('cloneNodes'), + bringToFront: action('bringToFront'), + bringForward: action('bringForward'), + sendBackward: action('sendBackward'), + sendToBack: action('sendToBack'), + alignLeft: action('alignLeft'), + alignCenter: action('alignCenter'), + alignRight: action('alignRight'), + alignTop: action('alignTop'), + alignMiddle: action('alignMiddle'), + alignBottom: action('alignBottom'), + distributeHorizontally: action('distributeHorizontally'), + distributeVertically: action('distributeVertically'), + createCustomElement: action('createCustomElement'), + groupNodes: action('groupNodes'), + ungroupNodes: action('ungroupNodes'), + undoHistory: action('undoHistory'), + redoHistory: action('redoHistory'), +}; + +storiesOf('components/WorkpadHeader/EditMenu', module) + .add('default', () => ( + <EditMenu selectedNodes={[]} groupIsSelected={false} hasPasteData={false} {...handlers} /> + )) + .add('clipboard data exists', () => ( + <EditMenu selectedNodes={[]} groupIsSelected={false} hasPasteData={true} {...handlers} /> + )) + .add('single element selected', () => ( + <EditMenu selectedNodes={['foo']} groupIsSelected={false} hasPasteData={false} {...handlers} /> + )) + .add('single grouped element selected', () => ( + <EditMenu + selectedNodes={['foo', 'bar']} + groupIsSelected={true} + hasPasteData={false} + {...handlers} + /> + )) + .add('2 elements selected', () => ( + <EditMenu + selectedNodes={['foo', 'bar']} + groupIsSelected={false} + hasPasteData={false} + {...handlers} + /> + )) + .add('3+ elements selected', () => ( + <EditMenu + selectedNodes={['foo', 'bar', 'fizz']} + groupIsSelected={false} + hasPasteData={false} + {...handlers} + /> + )); diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.tsx new file mode 100644 index 0000000000000..15191b8d416ff --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/edit_menu.tsx @@ -0,0 +1,448 @@ +/* + * 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, { Fragment, FunctionComponent, useState } from 'react'; +import PropTypes from 'prop-types'; +import { EuiButtonEmpty, EuiContextMenu, EuiIcon, EuiOverlayMask } from '@elastic/eui'; +import { ComponentStrings } from '../../../../i18n/components'; +import { ShortcutStrings } from '../../../../i18n/shortcuts'; +import { flattenPanelTree } from '../../../lib/flatten_panel_tree'; +import { Popover, ClosePopoverFn } from '../../popover'; +import { CustomElementModal } from '../../custom_element_modal'; +import { CONTEXT_MENU_TOP_BORDER_CLASSNAME } from '../../../../common/lib/constants'; + +const { WorkpadHeaderEditMenu: strings } = ComponentStrings; +const shortcutHelp = ShortcutStrings.getShortcutHelp(); + +export interface Props { + /** + * cuts selected elements + */ + cutNodes: () => void; + /** + * copies selected elements to clipboard + */ + copyNodes: () => void; + /** + * pastes elements stored in clipboard to page + */ + pasteNodes: () => void; + /** + * clones selected elements + */ + cloneNodes: () => void; + /** + * deletes selected elements + */ + deleteNodes: () => void; + /** + * moves selected element to top layer + */ + bringToFront: () => void; + /** + * moves selected element up one layer + */ + bringForward: () => void; + /** + * moves selected element down one layer + */ + sendBackward: () => void; + /** + * moves selected element to bottom layer + */ + sendToBack: () => void; + /** + * saves the selected elements as an custom-element saved object + */ + createCustomElement: (name: string, description: string, image: string) => void; + /** + * indicated whether the selected element is a group or not + */ + groupIsSelected: boolean; + /** + * only more than one selected element can be grouped + */ + selectedNodes: string[]; + /** + * groups selected elements + */ + groupNodes: () => void; + /** + * ungroups selected group + */ + ungroupNodes: () => void; + /** + * left align selected elements + */ + alignLeft: () => void; + /** + * center align selected elements + */ + alignCenter: () => void; + /** + * right align selected elements + */ + alignRight: () => void; + /** + * top align selected elements + */ + alignTop: () => void; + /** + * middle align selected elements + */ + alignMiddle: () => void; + /** + * bottom align selected elements + */ + alignBottom: () => void; + /** + * horizontally distribute selected elements + */ + distributeHorizontally: () => void; + /** + * vertically distribute selected elements + */ + distributeVertically: () => void; + /** + * Reverts last change to the workpad + */ + undoHistory: () => void; + /** + * Reapplies last reverted change to the workpad + */ + redoHistory: () => void; + /** + * Is there element clipboard data to paste? + */ + hasPasteData: boolean; +} + +export const EditMenu: FunctionComponent<Props> = ({ + cutNodes, + copyNodes, + pasteNodes, + deleteNodes, + cloneNodes, + bringToFront, + bringForward, + sendBackward, + sendToBack, + alignLeft, + alignCenter, + alignRight, + alignTop, + alignMiddle, + alignBottom, + distributeHorizontally, + distributeVertically, + createCustomElement, + selectedNodes, + groupIsSelected, + groupNodes, + ungroupNodes, + undoHistory, + redoHistory, + hasPasteData, +}) => { + const [isModalVisible, setModalVisible] = useState(false); + const showModal = () => setModalVisible(true); + const hideModal = () => setModalVisible(false); + + const handleSave = (name: string, description: string, image: string) => { + createCustomElement(name, description, image); + hideModal(); + }; + + const editControl = (togglePopover: React.MouseEventHandler<any>) => ( + <EuiButtonEmpty + size="xs" + aria-label={strings.getEditMenuLabel()} + onClick={togglePopover} + data-test-subj="canvasWorkpadEditMenuButton" + > + {strings.getEditMenuButtonLabel()} + </EuiButtonEmpty> + ); + + const getPanelTree = (closePopover: ClosePopoverFn) => { + const groupMenuItem = groupIsSelected + ? { + name: strings.getUngroupMenuItemLabel(), + className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, + icon: <EuiIcon type="empty" size="m" />, + onClick: () => { + ungroupNodes(); + closePopover(); + }, + } + : { + name: strings.getGroupMenuItemLabel(), + className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, + icon: <EuiIcon type="empty" size="m" />, + disabled: selectedNodes.length < 2, + onClick: () => { + groupNodes(); + closePopover(); + }, + }; + + const orderMenuItem = { + name: strings.getOrderMenuItemLabel(), + disabled: selectedNodes.length !== 1, // TODO: change to === 0 when we support relayering multiple elements + icon: <EuiIcon type="empty" size="m" />, + panel: { + id: 1, + title: strings.getOrderMenuItemLabel(), + items: [ + { + name: shortcutHelp.BRING_TO_FRONT, // TODO: check against current element position and disable if already top layer + icon: 'sortUp', + onClick: bringToFront, + }, + { + name: shortcutHelp.BRING_FORWARD, // TODO: same as above + icon: 'arrowUp', + onClick: bringForward, + }, + { + name: shortcutHelp.SEND_BACKWARD, // TODO: check against current element position and disable if already bottom layer + icon: 'arrowDown', + onClick: sendBackward, + }, + { + name: shortcutHelp.SEND_TO_BACK, // TODO: same as above + icon: 'sortDown', + onClick: sendToBack, + }, + ], + }, + }; + + const alignmentMenuItem = { + name: strings.getAlignmentMenuItemLabel(), + className: 'canvasContextMenu', + disabled: groupIsSelected || selectedNodes.length < 2, + icon: <EuiIcon type="empty" size="m" />, + panel: { + id: 2, + title: strings.getAlignmentMenuItemLabel(), + items: [ + { + name: strings.getLeftAlignMenuItemLabel(), + icon: 'editorItemAlignLeft', + onClick: () => { + alignLeft(); + closePopover(); + }, + }, + { + name: strings.getCenterAlignMenuItemLabel(), + icon: 'editorItemAlignCenter', + onClick: () => { + alignCenter(); + closePopover(); + }, + }, + { + name: strings.getRightAlignMenuItemLabel(), + icon: 'editorItemAlignRight', + onClick: () => { + alignRight(); + closePopover(); + }, + }, + { + name: strings.getTopAlignMenuItemLabel(), + icon: 'editorItemAlignTop', + onClick: () => { + alignTop(); + closePopover(); + }, + }, + { + name: strings.getMiddleAlignMenuItemLabel(), + icon: 'editorItemAlignMiddle', + onClick: () => { + alignMiddle(); + closePopover(); + }, + }, + { + name: strings.getBottomAlignMenuItemLabel(), + icon: 'editorItemAlignBottom', + onClick: () => { + alignBottom(); + closePopover(); + }, + }, + ], + }, + }; + + const distributionMenuItem = { + name: strings.getDistributionMenuItemLabel(), + className: 'canvasContextMenu', + disabled: groupIsSelected || selectedNodes.length < 3, + icon: <EuiIcon type="empty" size="m" />, + panel: { + id: 3, + title: strings.getAlignmentMenuItemLabel(), + items: [ + { + name: strings.getHorizontalDistributionMenuItemLabel(), + icon: 'editorDistributeHorizontal', + onClick: () => { + distributeHorizontally(); + closePopover(); + }, + }, + { + name: strings.getVerticalDistributionMenuItemLabel(), + icon: 'editorDistributeVertical', + onClick: () => { + distributeVertically(); + closePopover(); + }, + }, + ], + }, + }; + + const savedElementMenuItem = { + name: strings.getSaveElementMenuItemLabel(), + icon: <EuiIcon type="indexOpen" size="m" />, + disabled: selectedNodes.length < 1, + className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, + 'data-test-subj': 'canvasWorkpadEditMenu__saveElementButton', + onClick: () => { + showModal(); + closePopover(); + }, + }; + + const items = [ + { + // TODO: check history and disable when there are no more changes to revert + name: strings.getUndoMenuItemLabel(), + icon: <EuiIcon type="editorUndo" size="m" />, + onClick: () => { + undoHistory(); + }, + }, + { + // TODO: check history and disable when there are no more changes to reapply + name: strings.getRedoMenuItemLabel(), + icon: <EuiIcon type="editorRedo" size="m" />, + onClick: () => { + redoHistory(); + }, + }, + { + name: shortcutHelp.CUT, + icon: <EuiIcon type="cut" size="m" />, + className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, + disabled: selectedNodes.length < 1, + onClick: () => { + cutNodes(); + closePopover(); + }, + }, + { + name: shortcutHelp.COPY, + disabled: selectedNodes.length < 1, + icon: <EuiIcon type="copy" size="m" />, + onClick: () => { + copyNodes(); + }, + }, + { + name: shortcutHelp.PASTE, // TODO: can this be disabled if clipboard is empty? + icon: <EuiIcon type="copyClipboard" size="m" />, + disabled: !hasPasteData, + onClick: () => { + pasteNodes(); + closePopover(); + }, + }, + { + name: shortcutHelp.DELETE, + icon: <EuiIcon type="trash" size="m" />, + disabled: selectedNodes.length < 1, + onClick: () => { + deleteNodes(); + closePopover(); + }, + }, + { + name: shortcutHelp.CLONE, + icon: <EuiIcon type="empty" size="m" />, + disabled: selectedNodes.length < 1, + onClick: () => { + cloneNodes(); + closePopover(); + }, + }, + groupMenuItem, + orderMenuItem, + alignmentMenuItem, + distributionMenuItem, + savedElementMenuItem, + ]; + + return { + id: 0, + // title: strings.getEditMenuLabel(), + items, + }; + }; + + return ( + <Fragment> + <Popover button={editControl} panelPaddingSize="none" anchorPosition="downLeft"> + {({ closePopover }: { closePopover: ClosePopoverFn }) => ( + <EuiContextMenu + initialPanelId={0} + panels={flattenPanelTree(getPanelTree(closePopover))} + /> + )} + </Popover> + {isModalVisible ? ( + <EuiOverlayMask> + <CustomElementModal + title={strings.getCreateElementModalTitle()} + onSave={handleSave} + onCancel={hideModal} + /> + </EuiOverlayMask> + ) : null} + </Fragment> + ); +}; + +EditMenu.propTypes = { + cutNodes: PropTypes.func.isRequired, + copyNodes: PropTypes.func.isRequired, + pasteNodes: PropTypes.func.isRequired, + deleteNodes: PropTypes.func.isRequired, + cloneNodes: PropTypes.func.isRequired, + bringToFront: PropTypes.func.isRequired, + bringForward: PropTypes.func.isRequired, + sendBackward: PropTypes.func.isRequired, + sendToBack: PropTypes.func.isRequired, + alignLeft: PropTypes.func.isRequired, + alignCenter: PropTypes.func.isRequired, + alignRight: PropTypes.func.isRequired, + alignTop: PropTypes.func.isRequired, + alignMiddle: PropTypes.func.isRequired, + alignBottom: PropTypes.func.isRequired, + distributeHorizontally: PropTypes.func.isRequired, + distributeVertically: PropTypes.func.isRequired, + createCustomElement: PropTypes.func.isRequired, + selectedNodes: PropTypes.arrayOf(PropTypes.string).isRequired, + groupIsSelected: PropTypes.bool.isRequired, + groupNodes: PropTypes.func.isRequired, + ungroupNodes: PropTypes.func.isRequired, +}; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/index.ts b/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/index.ts new file mode 100644 index 0000000000000..a8bb7177dbd24 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/edit_menu/index.ts @@ -0,0 +1,123 @@ +/* + * 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 { connect } from 'react-redux'; +import { compose, withHandlers, withProps } from 'recompose'; +import { Dispatch } from 'redux'; +import { State, PositionedElement } from '../../../../types'; +import { getClipboardData } from '../../../lib/clipboard'; +// @ts-ignore Untyped local +import { flatten } from '../../../lib/aeroelastic/functional'; +// @ts-ignore Untyped local +import { globalStateUpdater } from '../../workpad_page/integration_utils'; +// @ts-ignore Untyped local +import { crawlTree } from '../../workpad_page/integration_utils'; +// @ts-ignore Untyped local +import { insertNodes, elementLayer, removeElements } from '../../../state/actions/elements'; +// @ts-ignore Untyped local +import { undoHistory, redoHistory } from '../../../state/actions/history'; +// @ts-ignore Untyped local +import { selectToplevelNodes } from '../../../state/actions/transient'; +import { + getSelectedPage, + getNodes, + getSelectedToplevelNodes, +} from '../../../state/selectors/workpad'; +import { + layerHandlerCreators, + clipboardHandlerCreators, + basicHandlerCreators, + groupHandlerCreators, + alignmentDistributionHandlerCreators, +} from '../../../lib/element_handler_creators'; +import { EditMenu as Component, Props as ComponentProps } from './edit_menu'; + +type LayoutState = any; + +type CommitFn = (type: string, payload: any) => LayoutState; + +interface OwnProps { + commit: CommitFn; +} + +const withGlobalState = ( + commit: CommitFn, + updateGlobalState: (layoutState: LayoutState) => void +) => (type: string, payload: any) => { + const newLayoutState = commit(type, payload); + if (newLayoutState.currentScene.gestureEnd) { + updateGlobalState(newLayoutState); + } +}; + +/* + * TODO: this is all copied from interactive_workpad_page and workpad_shortcuts + */ +const mapStateToProps = (state: State) => { + const pageId = getSelectedPage(state); + const nodes = getNodes(state, pageId) as PositionedElement[]; + const selectedToplevelNodes = getSelectedToplevelNodes(state); + const selectedPrimaryShapeObjects = selectedToplevelNodes + .map((id: string) => nodes.find((s: PositionedElement) => s.id === id)) + .filter((shape?: PositionedElement) => shape) as PositionedElement[]; + const selectedPersistentPrimaryNodes = flatten( + selectedPrimaryShapeObjects.map((shape: PositionedElement) => + nodes.find((n: PositionedElement) => n.id === shape.id) // is it a leaf or a persisted group? + ? [shape.id] + : nodes.filter((s: PositionedElement) => s.position.parent === shape.id).map(s => s.id) + ) + ); + const selectedNodeIds = flatten(selectedPersistentPrimaryNodes.map(crawlTree(nodes))); + + return { + pageId, + selectedToplevelNodes, + selectedNodes: selectedNodeIds.map((id: string) => nodes.find(s => s.id === id)), + state, + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + insertNodes: (selectedNodes: PositionedElement[], pageId: string) => + dispatch(insertNodes(selectedNodes, pageId)), + removeNodes: (nodeIds: string[], pageId: string) => dispatch(removeElements(nodeIds, pageId)), + selectToplevelNodes: (nodes: PositionedElement[]) => + dispatch( + selectToplevelNodes(nodes.filter((e: PositionedElement) => !e.position.parent).map(e => e.id)) + ), + elementLayer: (pageId: string, elementId: string, movement: number) => { + dispatch(elementLayer({ pageId, elementId, movement })); + }, + undoHistory: () => dispatch(undoHistory()), + redoHistory: () => dispatch(redoHistory()), + dispatch, +}); + +const mergeProps = ( + { state, selectedToplevelNodes, ...restStateProps }: ReturnType<typeof mapStateToProps>, + { dispatch, ...restDispatchProps }: ReturnType<typeof mapDispatchToProps>, + { commit }: OwnProps +) => { + const updateGlobalState = globalStateUpdater(dispatch, state); + + return { + ...restDispatchProps, + ...restStateProps, + commit: withGlobalState(commit, updateGlobalState), + groupIsSelected: + selectedToplevelNodes.length === 1 && selectedToplevelNodes[0].includes('group'), + }; +}; + +export const EditMenu = compose<ComponentProps, OwnProps>( + connect(mapStateToProps, mapDispatchToProps, mergeProps), + withProps(() => ({ hasPasteData: Boolean(getClipboardData()) })), + withHandlers(basicHandlerCreators), + withHandlers(clipboardHandlerCreators), + withHandlers(layerHandlerCreators), + withHandlers(groupHandlerCreators), + withHandlers(alignmentDistributionHandlerCreators) +)(Component); diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx index 5c420cf3a04c9..fbb5d70dfc55c 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/element_menu/element_menu.tsx @@ -139,7 +139,6 @@ export const ElementMenu: FunctionComponent<Props> = ({ return { id: 0, - title: strings.getElementMenuLabel(), items: [ elementListToMenuItems(textElements), elementListToMenuItems(shapeElements), diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.tsx index 1768adf9be79d..d651e649128f9 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.tsx @@ -32,6 +32,7 @@ export const RefreshControl = ({ doRefresh, inFlight }: Props) => ( iconType="refresh" aria-label={strings.getRefreshAriaLabel()} onClick={doRefresh} + data-test-subj="canvas-refresh-control" /> </EuiToolTip> ); diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx index 621077c29c368..2ac0591a1bdd4 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/share_menu/share_menu.tsx @@ -62,7 +62,6 @@ export const ShareMenu: FunctionComponent<Props> = ({ onCopy, onExport, getExpor const getPanelTree = (closePopover: ClosePopoverFn) => ({ id: 0, - title: strings.getShareWorkpadMessage(), items: [ { name: strings.getShareDownloadJSONTitle(), diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/__examples__/__snapshots__/view_menu.stories.storyshot b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/__examples__/__snapshots__/view_menu.stories.storyshot index e1ecee0e152be..eb45f97452ae1 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/__examples__/__snapshots__/view_menu.stories.storyshot +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/__examples__/__snapshots__/view_menu.stories.storyshot @@ -65,3 +65,69 @@ exports[`Storyshots components/WorkpadHeader/ViewMenu read only mode 1`] = ` </div> </div> `; + +exports[`Storyshots components/WorkpadHeader/ViewMenu with autoplay enabled 1`] = ` +<div + className="euiPopover euiPopover--anchorDownLeft" + container={null} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} +> + <div + className="euiPopover__anchor" + > + <button + aria-label="View options" + className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall" + onClick={[Function]} + type="button" + > + <span + className="euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + View + </span> + </span> + </button> + </div> +</div> +`; + +exports[`Storyshots components/WorkpadHeader/ViewMenu with refresh enabled 1`] = ` +<div + className="euiPopover euiPopover--anchorDownLeft" + container={null} + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} +> + <div + className="euiPopover__anchor" + > + <button + aria-label="View options" + className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall" + onClick={[Function]} + type="button" + > + <span + className="euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + View + </span> + </span> + </button> + </div> +</div> +`; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/__examples__/view_menu.stories.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/__examples__/view_menu.stories.tsx index 60837ac1218e6..5b4de05da3a3d 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/__examples__/view_menu.stories.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/__examples__/view_menu.stories.tsx @@ -8,32 +8,58 @@ import { action } from '@storybook/addon-actions'; import React from 'react'; import { ViewMenu } from '../view_menu'; +const handlers = { + setZoomScale: action('setZoomScale'), + zoomIn: action('zoomIn'), + zoomOut: action('zoomOut'), + toggleWriteable: action('toggleWriteable'), + resetZoom: action('resetZoom'), + enterFullscreen: action('enterFullscreen'), + doRefresh: action('doRefresh'), + fitToWindow: action('fitToWindow'), + setRefreshInterval: action('setRefreshInterval'), + setAutoplayInterval: action('setAutoplayInterval'), + enableAutoplay: action('enableAutoplay'), +}; + storiesOf('components/WorkpadHeader/ViewMenu', module) .add('edit mode', () => ( <ViewMenu isWriteable={true} zoomScale={1} - setZoomScale={action('setZoomScale')} - zoomIn={action('zoomIn')} - zoomOut={action('zoomOut')} - toggleWriteable={action('toggleWriteable')} - resetZoom={action('resetZoom')} - enterFullscreen={action('enterFullscreen')} - doRefresh={action('doRefresh')} - fitToWindow={action('fitToWindow')} + refreshInterval={0} + autoplayInterval={0} + autoplayEnabled={false} + {...handlers} /> )) .add('read only mode', () => ( <ViewMenu isWriteable={false} zoomScale={1} - setZoomScale={action('setZoomScale')} - zoomIn={action('zoomIn')} - zoomOut={action('zoomOut')} - toggleWriteable={action('toggleWriteable')} - resetZoom={action('resetZoom')} - enterFullscreen={action('enterFullscreen')} - doRefresh={action('doRefresh')} - fitToWindow={action('fitToWindow')} + refreshInterval={0} + autoplayInterval={0} + autoplayEnabled={false} + {...handlers} + /> + )) + .add('with refresh enabled', () => ( + <ViewMenu + isWriteable={false} + zoomScale={1} + refreshInterval={1000} + autoplayInterval={0} + autoplayEnabled={false} + {...handlers} + /> + )) + .add('with autoplay enabled', () => ( + <ViewMenu + isWriteable={false} + zoomScale={1} + refreshInterval={0} + autoplayInterval={5000} + autoplayEnabled={true} + {...handlers} /> )); diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/auto_refresh_controls.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/auto_refresh_controls.tsx similarity index 83% rename from x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/auto_refresh_controls.tsx rename to x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/auto_refresh_controls.tsx index 97d8920d50dd3..cfd599b1d9f3f 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/auto_refresh_controls.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/auto_refresh_controls.tsx @@ -22,7 +22,6 @@ import { htmlIdGenerator, } from '@elastic/eui'; import { timeDuration } from '../../../lib/time_duration'; -import { RefreshControl } from '../refresh_control'; import { CustomInterval } from './custom_interval'; import { ComponentStrings, UnitStrings } from '../../../../i18n'; @@ -69,7 +68,11 @@ export const AutoRefreshControls = ({ refreshInterval, setRefresh, disableInterv const intervalTitleId = generateId(); return ( - <EuiFlexGroup direction="column" justifyContent="spaceBetween"> + <EuiFlexGroup + direction="column" + justifyContent="spaceBetween" + className="canvasViewMenu__kioskSettings" + > <EuiFlexItem grow={false}> <EuiFlexGroup alignItems="center" justifyContent="spaceAround" gutterSize="xs"> <EuiFlexItem> @@ -97,9 +100,6 @@ export const AutoRefreshControls = ({ refreshInterval, setRefresh, disableInterv </EuiToolTip> </EuiFlexItem> ) : null} - <EuiFlexItem grow={false}> - <RefreshControl /> - </EuiFlexItem> </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> @@ -112,16 +112,6 @@ export const AutoRefreshControls = ({ refreshInterval, setRefresh, disableInterv <EuiSpacer size="s" /> <EuiText size="s"> <ListGroup aria-labelledby={intervalTitleId} className="canvasControlSettings__list"> - <RefreshItem - duration={5000} - label={getSecondsText(5)} - descriptionId={intervalTitleId} - /> - <RefreshItem - duration={15000} - label={getSecondsText(15)} - descriptionId={intervalTitleId} - /> <RefreshItem duration={30000} label={getSecondsText(30)} @@ -137,11 +127,6 @@ export const AutoRefreshControls = ({ refreshInterval, setRefresh, disableInterv label={getMinutesText(5)} descriptionId={intervalTitleId} /> - <RefreshItem - duration={900000} - label={getMinutesText(15)} - descriptionId={intervalTitleId} - /> <RefreshItem duration={1800000} label={getMinutesText(30)} @@ -152,16 +137,6 @@ export const AutoRefreshControls = ({ refreshInterval, setRefresh, disableInterv label={getHoursText(1)} descriptionId={intervalTitleId} /> - <RefreshItem - duration={7200000} - label={getHoursText(2)} - descriptionId={intervalTitleId} - /> - <RefreshItem - duration={21600000} - label={getHoursText(6)} - descriptionId={intervalTitleId} - /> <RefreshItem duration={43200000} label={getHoursText(12)} @@ -175,7 +150,6 @@ export const AutoRefreshControls = ({ refreshInterval, setRefresh, disableInterv </ListGroup> </EuiText> </EuiFlexItem> - <EuiFlexItem grow={false}> <CustomInterval onSubmit={value => setRefresh(value)} /> </EuiFlexItem> diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/custom_interval.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/custom_interval.tsx similarity index 100% rename from x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/custom_interval.tsx rename to x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/custom_interval.tsx diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/index.ts b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/index.ts index eee613183639c..e1ad9782c8aef 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/index.ts +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/index.ts @@ -15,13 +15,21 @@ import { fetchAllRenderables } from '../../../state/actions/elements'; // @ts-ignore Untyped local import { setZoomScale, setFullscreen, selectToplevelNodes } from '../../../state/actions/transient'; // @ts-ignore Untyped local -import { setWriteable } from '../../../state/actions/workpad'; +import { + setWriteable, + setRefreshInterval, + enableAutoplay, + setAutoplayInterval, + // @ts-ignore Untyped local +} from '../../../state/actions/workpad'; import { getZoomScale, canUserWrite } from '../../../state/selectors/app'; import { getWorkpadBoundingBox, getWorkpadWidth, getWorkpadHeight, isWriteable, + getRefreshInterval, + getAutoplay, } from '../../../state/selectors/workpad'; import { ViewMenu as Component, Props as ComponentProps } from './view_menu'; import { getFitZoomScale } from './lib/get_fit_zoom_scale'; @@ -40,24 +48,35 @@ interface DispatchProps { setFullscreen: (showFullscreen: boolean) => void; } -const mapStateToProps = (state: State) => ({ - zoomScale: getZoomScale(state), - boundingBox: getWorkpadBoundingBox(state), - workpadWidth: getWorkpadWidth(state), - workpadHeight: getWorkpadHeight(state), - isWriteable: isWriteable(state) && canUserWrite(state), -}); +const mapStateToProps = (state: State) => { + const { enabled, interval } = getAutoplay(state); + + return { + zoomScale: getZoomScale(state), + boundingBox: getWorkpadBoundingBox(state), + workpadWidth: getWorkpadWidth(state), + workpadHeight: getWorkpadHeight(state), + isWriteable: isWriteable(state) && canUserWrite(state), + refreshInterval: getRefreshInterval(state), + autoplayEnabled: enabled, + autoplayInterval: interval, + }; +}; const mapDispatchToProps = (dispatch: Dispatch) => ({ setZoomScale: (scale: number) => dispatch(setZoomScale(scale)), setWriteable: (isWorkpadWriteable: boolean) => dispatch(setWriteable(isWorkpadWriteable)), setFullscreen: (value: boolean) => { dispatch(setFullscreen(value)); + if (value) { dispatch(selectToplevelNodes([])); } }, doRefresh: () => dispatch(fetchAllRenderables()), + setRefreshInterval: (interval: number) => dispatch(setRefreshInterval(interval)), + enableAutoplay: (autoplay: number) => dispatch(enableAutoplay(autoplay)), + setAutoplayInterval: (interval: number) => dispatch(setAutoplayInterval(interval)), }); const mergeProps = ( @@ -66,13 +85,15 @@ const mergeProps = ( ownProps: ComponentProps ): ComponentProps => { const { boundingBox, workpadWidth, workpadHeight, ...remainingStateProps } = stateProps; + return { ...remainingStateProps, ...dispatchProps, ...ownProps, toggleWriteable: () => dispatchProps.setWriteable(!stateProps.isWriteable), enterFullscreen: () => dispatchProps.setFullscreen(true), - fitToWindow: () => getFitZoomScale(boundingBox, workpadWidth, workpadHeight), + fitToWindow: () => + dispatchProps.setZoomScale(getFitZoomScale(boundingBox, workpadWidth, workpadHeight)), }; }; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/kiosk_controls.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/kiosk_controls.tsx similarity index 86% rename from x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/kiosk_controls.tsx rename to x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/kiosk_controls.tsx index 9e6f0a91c6120..e63eed9f9df53 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/control_settings/kiosk_controls.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/kiosk_controls.tsx @@ -14,7 +14,6 @@ import { EuiHorizontalRule, EuiLink, EuiSpacer, - EuiSwitch, EuiText, EuiFlexItem, EuiFlexGroup, @@ -29,9 +28,7 @@ const { time: timeStrings } = UnitStrings; const { getSecondsText, getMinutesText } = timeStrings; interface Props { - autoplayEnabled: boolean; autoplayInterval: number; - onSetEnabled: (enabled: boolean) => void; onSetInterval: (interval: number | undefined) => void; } @@ -54,12 +51,7 @@ const ListGroup = ({ children, ...rest }: ListGroupProps) => ( const generateId = htmlIdGenerator(); -export const KioskControls = ({ - autoplayEnabled, - autoplayInterval, - onSetEnabled, - onSetInterval, -}: Props) => { +export const KioskControls = ({ autoplayInterval, onSetInterval }: Props) => { const RefreshItem = ({ duration, label, descriptionId }: RefreshItemProps) => ( <li> <EuiLink onClick={() => onSetInterval(duration)} aria-describedby={descriptionId}> @@ -72,7 +64,11 @@ export const KioskControls = ({ const intervalTitleId = generateId(); return ( - <EuiFlexGroup direction="column" justifyContent="spaceBetween"> + <EuiFlexGroup + direction="column" + justifyContent="spaceBetween" + className="canvasViewMenu__kioskSettings" + > <EuiFlexItem grow={false}> <EuiDescriptionList textStyle="reverse"> <EuiDescriptionListTitle>{strings.getTitle()}</EuiDescriptionListTitle> @@ -81,14 +77,6 @@ export const KioskControls = ({ </EuiDescriptionListDescription> </EuiDescriptionList> <EuiHorizontalRule margin="m" /> - - <EuiSwitch - checked={autoplayEnabled} - label={strings.getCycleToggleSwitch()} - onChange={ev => onSetEnabled(ev.target.checked)} - /> - <EuiSpacer size="m" /> - <EuiTitle size="xxxs" id={intervalTitleId}> <p>{strings.getCycleFormLabel()}</p> </EuiTitle> @@ -137,8 +125,6 @@ export const KioskControls = ({ }; KioskControls.propTypes = { - autoplayEnabled: PropTypes.bool.isRequired, autoplayInterval: PropTypes.number.isRequired, - onSetEnabled: PropTypes.func.isRequired, onSetInterval: PropTypes.func.isRequired, }; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/view_menu.scss b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/view_menu.scss new file mode 100644 index 0000000000000..c4e06881981c7 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/view_menu.scss @@ -0,0 +1,4 @@ +.canvasViewMenu__kioskSettings, +.canvasViewMenu__refreshSettings { + padding: $euiSize; +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/view_menu.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/view_menu.tsx index d1e08c5809579..b6f108cda37f6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/view_menu.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/view_menu/view_menu.tsx @@ -12,10 +12,16 @@ import { EuiIcon, EuiContextMenuPanelItemDescriptor, } from '@elastic/eui'; -import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '../../../../common/lib/constants'; +import { + MAX_ZOOM_LEVEL, + MIN_ZOOM_LEVEL, + CONTEXT_MENU_TOP_BORDER_CLASSNAME, +} from '../../../../common/lib/constants'; import { ComponentStrings } from '../../../../i18n/components'; import { flattenPanelTree } from '../../../lib/flatten_panel_tree'; import { Popover, ClosePopoverFn } from '../../popover'; +import { AutoRefreshControls } from './auto_refresh_controls'; +import { KioskControls } from './kiosk_controls'; const { WorkpadHeaderViewMenu: strings } = ComponentStrings; @@ -62,10 +68,33 @@ export interface Props { * triggers a refresh of the workpad */ doRefresh: () => void; + /** + * Current auto refresh interval + */ + refreshInterval: number; + /** + * Sets auto refresh interval + */ + setRefreshInterval: (interval?: number) => void; + /** + * Is autoplay enabled? + */ + autoplayEnabled: boolean; + /** + * Current autoplay interval + */ + autoplayInterval: number; + /** + * Enables autoplay + */ + enableAutoplay: (autoplay: boolean) => void; + /** + * Sets autoplay interval + */ + setAutoplayInterval: (interval?: number) => void; } export const ViewMenu: FunctionComponent<Props> = ({ - doRefresh, enterFullscreen, fitToWindow, isWriteable, @@ -75,7 +104,20 @@ export const ViewMenu: FunctionComponent<Props> = ({ zoomIn, zoomOut, zoomScale, + doRefresh, + refreshInterval, + setRefreshInterval, + autoplayEnabled, + autoplayInterval, + enableAutoplay, + setAutoplayInterval, }) => { + const setRefresh = (val: number | undefined) => setRefreshInterval(val); + + const disableInterval = () => { + setRefresh(0); + }; + const viewControl = (togglePopover: React.MouseEventHandler<any>) => ( <EuiButtonEmpty size="xs" aria-label={strings.getViewMenuLabel()} onClick={togglePopover}> {strings.getViewMenuButtonLabel()} @@ -121,36 +163,76 @@ export const ViewMenu: FunctionComponent<Props> = ({ const getPanelTree = (closePopover: ClosePopoverFn) => ({ id: 0, - title: strings.getViewMenuLabel(), items: [ + { + name: strings.getRefreshMenuItemLabel(), + icon: 'refresh', + onClick: () => { + doRefresh(); + }, + }, + { + name: strings.getRefreshSettingsMenuItemLabel(), + icon: 'empty', + panel: { + id: 1, + title: strings.getRefreshSettingsMenuItemLabel(), + content: ( + <AutoRefreshControls + refreshInterval={refreshInterval} + setRefresh={val => setRefresh(val)} + disableInterval={() => disableInterval()} + /> + ), + }, + }, { name: strings.getFullscreenMenuItemLabel(), icon: <EuiIcon type="fullScreen" size="m" />, + className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, onClick: () => { enterFullscreen(); closePopover(); }, }, { - name: isWriteable ? strings.getHideEditModeLabel() : strings.getShowEditModeLabel(), - icon: <EuiIcon type={isWriteable ? 'eyeClosed' : 'eye'} size="m" />, + name: autoplayEnabled + ? strings.getAutoplayOffMenuItemLabel() + : strings.getAutoplayOnMenuItemLabel(), + icon: autoplayEnabled ? 'stop' : 'play', onClick: () => { - toggleWriteable(); + enableAutoplay(!autoplayEnabled); closePopover(); }, }, { - name: strings.getRefreshMenuItemLabel(), - icon: 'refresh', + name: strings.getAutoplaySettingsMenuItemLabel(), + icon: 'empty', + panel: { + id: 2, + title: strings.getAutoplaySettingsMenuItemLabel(), + content: ( + <KioskControls + autoplayInterval={autoplayInterval} + onSetInterval={setAutoplayInterval} + /> + ), + }, + }, + { + name: isWriteable ? strings.getHideEditModeLabel() : strings.getShowEditModeLabel(), + icon: <EuiIcon type={isWriteable ? 'eyeClosed' : 'eye'} size="m" />, + className: CONTEXT_MENU_TOP_BORDER_CLASSNAME, onClick: () => { - doRefresh(); + toggleWriteable(); + closePopover(); }, }, { name: strings.getZoomMenuItemLabel(), icon: 'magnifyWithPlus', panel: { - id: 1, + id: 3, title: strings.getZoomMenuItemLabel(), items: getZoomMenuItems(), }, @@ -161,7 +243,11 @@ export const ViewMenu: FunctionComponent<Props> = ({ return ( <Popover button={viewControl} panelPaddingSize="none" anchorPosition="downLeft"> {({ closePopover }: { closePopover: ClosePopoverFn }) => ( - <EuiContextMenu initialPanelId={0} panels={flattenPanelTree(getPanelTree(closePopover))} /> + <EuiContextMenu + initialPanelId={0} + panels={flattenPanelTree(getPanelTree(closePopover))} + className="canvasViewMenu" + /> )} </Popover> ); @@ -169,4 +255,19 @@ export const ViewMenu: FunctionComponent<Props> = ({ ViewMenu.propTypes = { isWriteable: PropTypes.bool.isRequired, + zoomScale: PropTypes.number.isRequired, + fitToWindow: PropTypes.func.isRequired, + setZoomScale: PropTypes.func.isRequired, + zoomIn: PropTypes.func.isRequired, + zoomOut: PropTypes.func.isRequired, + resetZoom: PropTypes.func.isRequired, + toggleWriteable: PropTypes.func.isRequired, + enterFullscreen: PropTypes.func.isRequired, + doRefresh: PropTypes.func.isRequired, + refreshInterval: PropTypes.number.isRequired, + setRefreshInterval: PropTypes.func.isRequired, + autoplayEnabled: PropTypes.bool.isRequired, + autoplayInterval: PropTypes.number.isRequired, + enableAutoplay: PropTypes.func.isRequired, + setAutoplayInterval: PropTypes.func.isRequired, }; diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_header/workpad_header.tsx b/x-pack/legacy/plugins/canvas/public/components/workpad_header/workpad_header.tsx index 253e6c68cfc9e..4aab8280a9f24 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_header/workpad_header.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_header/workpad_header.tsx @@ -11,11 +11,11 @@ import { Shortcuts } from 'react-shortcuts'; import { EuiFlexItem, EuiFlexGroup, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { ComponentStrings } from '../../../i18n'; import { ToolTipShortcut } from '../tool_tip_shortcut/'; -import { ControlSettings } from './control_settings'; // @ts-ignore untyped local import { RefreshControl } from './refresh_control'; // @ts-ignore untyped local import { FullscreenControl } from './fullscreen_control'; +import { EditMenu } from './edit_menu'; import { ElementMenu } from './element_menu'; import { ShareMenu } from './share_menu'; import { ViewMenu } from './view_menu'; @@ -26,12 +26,14 @@ export interface Props { isWriteable: boolean; toggleWriteable: () => void; canUserWrite: boolean; + commit: (type: string, payload: any) => any; } export const WorkpadHeader: FunctionComponent<Props> = ({ isWriteable, canUserWrite, toggleWriteable, + commit, }) => { const keyHandler = (action: string) => { if (action === 'EDITING') { @@ -101,10 +103,10 @@ export const WorkpadHeader: FunctionComponent<Props> = ({ <ViewMenu /> </EuiFlexItem> <EuiFlexItem grow={false}> - <ShareMenu /> + <EditMenu commit={commit} /> </EuiFlexItem> <EuiFlexItem grow={false}> - <ControlSettings /> + <ShareMenu /> </EuiFlexItem> </EuiFlexGroup> </EuiFlexItem> @@ -122,7 +124,7 @@ export const WorkpadHeader: FunctionComponent<Props> = ({ )} <EuiToolTip position="bottom" content={getEditToggleToolTip()}> <EuiButtonIcon - iconType={isWriteable ? 'eye' : 'eyeClosed'} + iconType={isWriteable ? 'eyeClosed' : 'eye'} onClick={toggleWriteable} size="s" aria-label={getEditToggleToolTipText()} diff --git a/x-pack/legacy/plugins/canvas/public/functions/filters.ts b/x-pack/legacy/plugins/canvas/public/functions/filters.ts index 2a3bc481d7dae..16d0bb0fff708 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 e59d798108945..7e38e6e710b81 100644 --- a/x-pack/legacy/plugins/canvas/public/functions/timelion.ts +++ b/x-pack/legacy/plugins/canvas/public/functions/timelion.ts @@ -10,8 +10,8 @@ import { TimeRange } from 'src/plugins/data/common'; import { ExpressionFunctionDefinition, DatatableRow } from 'src/plugins/expressions/public'; import { fetch } from '../../common/lib/fetch'; // @ts-ignore untyped local -import { buildBoolArray } from '../../server/lib/build_bool_array'; -import { Datatable, Filter } from '../../types'; +import { buildBoolArray } from '../../public/lib/build_bool_array'; +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<Datatable> >; @@ -94,11 +94,10 @@ export function timelionFunctionFactory(initialize: InitializeArguments): () => fn: (input, args): Promise<Datatable> => { // 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/legacy.ts b/x-pack/legacy/plugins/canvas/public/legacy.ts index f83887bbcbdfd..f4a2b309b3499 100644 --- a/x-pack/legacy/plugins/canvas/public/legacy.ts +++ b/x-pack/legacy/plugins/canvas/public/legacy.ts @@ -9,7 +9,6 @@ import { CanvasStartDeps, CanvasSetupDeps } from './plugin'; // eslint-disable-l // @ts-ignore Untyped Kibana Lib import chrome, { loadingCount } from 'ui/chrome'; // eslint-disable-line import/order -import { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url'; // eslint-disable-line import/order // @ts-ignore Untyped Kibana Lib import { formatMsg } from '../../../../../src/plugins/kibana_legacy/public'; // eslint-disable-line import/order @@ -32,12 +31,6 @@ const shimStartPlugins: CanvasStartDeps = { expressions: npStart.plugins.expressions, inspector: npStart.plugins.inspector, uiActions: npStart.plugins.uiActions, - __LEGACY: { - // ToDo: Copy directly into canvas - absoluteToParsedUrl, - // ToDo: Won't be a part of New Platform. Will need to handle internally - trackSubUrlForApp: chrome.trackSubUrlForApp, - }, }; // These methods are intended to be a replacement for import from 'ui/whatever' 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 b422a9451293f..77be181d47378 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 1a5d2119a94b6..aa915d0d3d02a 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/public/lib/history_provider.js b/x-pack/legacy/plugins/canvas/public/lib/history_provider.js index 59b6b88fa38c3..4ff9f0b9d4605 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/history_provider.js +++ b/x-pack/legacy/plugins/canvas/public/lib/history_provider.js @@ -6,7 +6,7 @@ import lzString from 'lz-string'; import { createMemoryHistory, parsePath, createPath } from 'history'; -import createHashStateHistory from 'history-extra'; +import createHashStateHistory from 'history-extra/dist/createHashStateHistory'; import { getWindow } from './get_window'; function wrapHistoryInstance(history) { @@ -134,7 +134,7 @@ function wrapHistoryInstance(history) { return wrappedHistory; } -const instances = new WeakMap(); +let instances = new WeakMap(); const getHistoryInstance = win => { // if no window object, use memory module @@ -158,3 +158,7 @@ export const historyProvider = (win = getWindow()) => { return wrappedInstance; }; + +export const destroyHistory = () => { + instances = new WeakMap(); +}; diff --git a/x-pack/legacy/plugins/canvas/public/lib/router_provider.js b/x-pack/legacy/plugins/canvas/public/lib/router_provider.js index 134875cce681a..42960baa00de1 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/router_provider.js +++ b/x-pack/legacy/plugins/canvas/public/lib/router_provider.js @@ -110,7 +110,19 @@ export function routerProvider(routes) { return unlisten; // return function to remove change handler }, + stop: () => { + for (const listener of componentListeners) { + listener(); + } + }, }; return router; } + +export const stopRouter = () => { + if (router) { + router.stop(); + router = undefined; + } +}; diff --git a/x-pack/legacy/plugins/canvas/public/plugin.tsx b/x-pack/legacy/plugins/canvas/public/plugin.tsx index baeb4ebd453d2..1e85aad6328a5 100644 --- a/x-pack/legacy/plugins/canvas/public/plugin.tsx +++ b/x-pack/legacy/plugins/canvas/public/plugin.tsx @@ -4,8 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Chrome } from 'ui/chrome'; -import { CoreSetup, CoreStart, Plugin } from '../../../../../src/core/public'; +import { + CoreSetup, + CoreStart, + Plugin, + AppMountParameters, + DEFAULT_APP_CATEGORIES, +} from '../../../../../src/core/public'; import { HomePublicPluginSetup } from '../../../../../src/plugins/home/public'; import { initLoadingIndicator } from './lib/loading_indicator'; import { featureCatalogueEntry } from './feature_catalogue_entry'; @@ -40,12 +45,7 @@ export interface CanvasStartDeps { embeddable: EmbeddableStart; expressions: ExpressionsStart; inspector: InspectorStart; - uiActions: UiActionsStart; - __LEGACY: { - absoluteToParsedUrl: (url: string, basePath: string) => any; - trackSubUrlForApp: Chrome['trackSubUrlForApp']; - }; } /** @@ -63,7 +63,6 @@ export class CanvasPlugin implements Plugin<CanvasSetup, CanvasStart, CanvasSetupDeps, CanvasStartDeps> { // TODO: Do we want to completely move canvas_plugin_src into it's own plugin? private srcPlugin = new CanvasSrcPlugin(); - private startPlugins: CanvasStartDeps | undefined; public setup(core: CoreSetup<CanvasStartDeps>, plugins: CanvasSetupDeps) { const { api: canvasApi, registries } = getPluginApi(plugins.expressions); @@ -71,28 +70,19 @@ export class CanvasPlugin this.srcPlugin.setup(core, { canvas: canvasApi }); core.application.register({ + category: DEFAULT_APP_CATEGORIES.analyze, id: 'canvas', - title: 'Canvas App', - mount: async (context, params) => { + title: 'Canvas', + euiIconType: 'canvasApp', + order: 0, // need to figure out if this is the proper order for us + mount: async (params: AppMountParameters) => { // Load application bundle const { renderApp, initializeCanvas, teardownCanvas } = await import('./application'); // Get start services const [coreStart, depsStart] = await core.getStartServices(); - // TODO: We only need this to get the __LEGACY stuff that isn't coming from getStartSevices. - // We won't need this as soon as we move over to NP Completely - if (!this.startPlugins) { - throw new Error('Start Plugins not ready at mount time'); - } - - const canvasStore = await initializeCanvas( - core, - coreStart, - plugins, - this.startPlugins, - registries - ); + const canvasStore = await initializeCanvas(core, coreStart, plugins, depsStart, registries); const unmount = renderApp(coreStart, depsStart, params, canvasStore); @@ -127,7 +117,6 @@ export class CanvasPlugin } public start(core: CoreStart, plugins: CanvasStartDeps) { - this.startPlugins = plugins; this.srcPlugin.start(core, plugins); initLoadingIndicator(core.http.addLoadingCountSource); } diff --git a/x-pack/legacy/plugins/canvas/public/state/actions/elements.js b/x-pack/legacy/plugins/canvas/public/state/actions/elements.js index f4a3393b8962d..5ec8eb6137f2b 100644 --- a/x-pack/legacy/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/legacy/plugins/canvas/public/state/actions/elements.js @@ -5,7 +5,7 @@ */ import { createAction } from 'redux-actions'; -import { createThunk } from 'redux-thunks'; +import { createThunk } from 'redux-thunks/cjs'; import immutable from 'object-path-immutable'; import { get, pick, cloneDeep, without } from 'lodash'; import { toExpression, safeElementFromExpression } from '@kbn/interpreter/common'; @@ -116,7 +116,6 @@ export const fetchContext = createThunk( const fetchRenderableWithContextFn = ({ dispatch }, element, ast, context) => { const argumentPath = [element.id, 'expressionRenderable']; - dispatch( args.setLoading({ path: argumentPath, diff --git a/x-pack/legacy/plugins/canvas/public/state/actions/workpad.js b/x-pack/legacy/plugins/canvas/public/state/actions/workpad.js index 5a7fb76ca868c..167c156dce998 100644 --- a/x-pack/legacy/plugins/canvas/public/state/actions/workpad.js +++ b/x-pack/legacy/plugins/canvas/public/state/actions/workpad.js @@ -5,7 +5,7 @@ */ import { createAction } from 'redux-actions'; -import { createThunk } from 'redux-thunks'; +import { createThunk } from 'redux-thunks/cjs'; import { without, includes } from 'lodash'; import { getWorkpadColors } from '../selectors/workpad'; import { fetchAllRenderables } from './elements'; diff --git a/x-pack/legacy/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/legacy/plugins/canvas/public/state/selectors/workpad.ts index 1623035bd25ba..80a7c34e8bef5 100644 --- a/x-pack/legacy/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/legacy/plugins/canvas/public/state/selectors/workpad.ts @@ -363,7 +363,11 @@ export function getNodesForPage(page: CanvasPage, withAst: boolean): CanvasEleme } // todo unify or DRY up with `getElements` -export function getNodes(state: State, pageId: string, withAst = true): CanvasElement[] { +export function getNodes( + state: State, + pageId: string, + withAst = true +): CanvasElement[] | PositionedElement[] { const id = pageId || getSelectedPage(state); if (!id) { return []; diff --git a/x-pack/legacy/plugins/canvas/public/state/store.js b/x-pack/legacy/plugins/canvas/public/state/store.js index 760e7a8609978..891f30ca4114f 100644 --- a/x-pack/legacy/plugins/canvas/public/state/store.js +++ b/x-pack/legacy/plugins/canvas/public/state/store.js @@ -22,9 +22,18 @@ export function createStore(initialState) { const rootReducer = getRootReducer(initialState); store = createReduxStore(rootReducer, initialState, middleware); + return store; } +export function destroyStore() { + if (store) { + // Replace reducer so that anything that gets fired after navigating away doesn't really do anything + store.replaceReducer(state => state); + } + store = undefined; +} + export function getState() { return store.getState(); } diff --git a/x-pack/legacy/plugins/canvas/public/store.ts b/x-pack/legacy/plugins/canvas/public/store.ts index 0a378979f6ad9..1460932101725 100644 --- a/x-pack/legacy/plugins/canvas/public/store.ts +++ b/x-pack/legacy/plugins/canvas/public/store.ts @@ -5,7 +5,7 @@ */ // @ts-ignore Untyped local -import { createStore as createReduxStore } from './state/store'; +import { createStore as createReduxStore, destroyStore as destroy } from './state/store'; // @ts-ignore Untyped local import { getInitialState } from './state/initial_state'; @@ -29,3 +29,7 @@ export async function createStore(core: CoreSetup, plugins: CanvasSetupDeps) { return createReduxStore(initialState); } + +export function destroyStore() { + destroy(); +} diff --git a/x-pack/legacy/plugins/canvas/public/style/index.scss b/x-pack/legacy/plugins/canvas/public/style/index.scss index ba0845862368a..7b4e1271cca1d 100644 --- a/x-pack/legacy/plugins/canvas/public/style/index.scss +++ b/x-pack/legacy/plugins/canvas/public/style/index.scss @@ -51,9 +51,9 @@ @import '../components/toolbar/tray/tray'; @import '../components/tooltip_annotation/tooltip_annotation'; @import '../components/workpad/workpad'; -@import '../components/workpad_header/control_settings/control_settings'; @import '../components/workpad_header/element_menu/element_menu'; @import '../components/workpad_header/share_menu/share_menu'; +@import '../components/workpad_header/view_menu/view_menu'; @import '../components/workpad_loader/workpad_loader'; @import '../components/workpad_loader/workpad_dropzone/workpad_dropzone'; @import '../components/workpad_page/workpad_page'; diff --git a/x-pack/legacy/plugins/canvas/public/style/main.scss b/x-pack/legacy/plugins/canvas/public/style/main.scss index 3d41649397190..4387557cbc9f2 100644 --- a/x-pack/legacy/plugins/canvas/public/style/main.scss +++ b/x-pack/legacy/plugins/canvas/public/style/main.scss @@ -3,6 +3,12 @@ */ $canvasElementCardWidth: 210px; + +.canvas.canvasContainerWrapper { + display: flex; + flex-grow: 1; +} + .canvas.canvasContainer { display: flex; flex-grow: 1; diff --git a/x-pack/legacy/plugins/canvas/public/transitions/fade/fade.css b/x-pack/legacy/plugins/canvas/public/transitions/fade/fade.scss similarity index 100% rename from x-pack/legacy/plugins/canvas/public/transitions/fade/fade.css rename to x-pack/legacy/plugins/canvas/public/transitions/fade/fade.scss diff --git a/x-pack/legacy/plugins/canvas/public/transitions/fade/index.ts b/x-pack/legacy/plugins/canvas/public/transitions/fade/index.ts index 71bad81f8b9b8..ad69fae848628 100644 --- a/x-pack/legacy/plugins/canvas/public/transitions/fade/index.ts +++ b/x-pack/legacy/plugins/canvas/public/transitions/fade/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import './fade.css'; +import './fade.scss'; import { TransitionStrings } from '../../../i18n'; diff --git a/x-pack/legacy/plugins/canvas/public/transitions/rotate/index.ts b/x-pack/legacy/plugins/canvas/public/transitions/rotate/index.ts index 0a160d3719290..4a6915e17ee26 100644 --- a/x-pack/legacy/plugins/canvas/public/transitions/rotate/index.ts +++ b/x-pack/legacy/plugins/canvas/public/transitions/rotate/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import './rotate.css'; +import './rotate.scss'; import { TransitionStrings } from '../../../i18n'; diff --git a/x-pack/legacy/plugins/canvas/public/transitions/rotate/rotate.css b/x-pack/legacy/plugins/canvas/public/transitions/rotate/rotate.scss similarity index 100% rename from x-pack/legacy/plugins/canvas/public/transitions/rotate/rotate.css rename to x-pack/legacy/plugins/canvas/public/transitions/rotate/rotate.scss diff --git a/x-pack/legacy/plugins/canvas/public/transitions/slide/index.ts b/x-pack/legacy/plugins/canvas/public/transitions/slide/index.ts index 904acf9f26e76..c0dfb9295c212 100644 --- a/x-pack/legacy/plugins/canvas/public/transitions/slide/index.ts +++ b/x-pack/legacy/plugins/canvas/public/transitions/slide/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import './slide.css'; +import './slide.scss'; import { TransitionStrings } from '../../../i18n'; diff --git a/x-pack/legacy/plugins/canvas/public/transitions/slide/slide.css b/x-pack/legacy/plugins/canvas/public/transitions/slide/slide.scss similarity index 100% rename from x-pack/legacy/plugins/canvas/public/transitions/slide/slide.css rename to x-pack/legacy/plugins/canvas/public/transitions/slide/slide.scss diff --git a/x-pack/legacy/plugins/canvas/public/transitions/zoom/index.ts b/x-pack/legacy/plugins/canvas/public/transitions/zoom/index.ts index 44dc19e6855d2..7aecc56197a6f 100644 --- a/x-pack/legacy/plugins/canvas/public/transitions/zoom/index.ts +++ b/x-pack/legacy/plugins/canvas/public/transitions/zoom/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import './zoom.css'; +import './zoom.scss'; import { TransitionStrings } from '../../../i18n'; diff --git a/x-pack/legacy/plugins/canvas/public/transitions/zoom/zoom.css b/x-pack/legacy/plugins/canvas/public/transitions/zoom/zoom.css deleted file mode 100644 index 6811a6f178907..0000000000000 --- a/x-pack/legacy/plugins/canvas/public/transitions/zoom/zoom.css +++ /dev/null @@ -1,33 +0,0 @@ -@keyframes zoomIn { - from { - opacity: 0; - transform: scale3d(0.3, 0.3, 0.3); - } - - 50% { - opacity: 1; - } -} - -.zoomIn { - animation-name: 'zoomIn'; -} - -@keyframes zoomOut { - from { - opacity: 1; - } - - 50% { - opacity: 0; - transform: scale3d(0.3, 0.3, 0.3); - } - - to { - opacity: 0; - } -} - -.zoomOut { - animation-name: 'zoomOut'; -} diff --git a/x-pack/legacy/plugins/canvas/public/transitions/zoom/zoom.scss b/x-pack/legacy/plugins/canvas/public/transitions/zoom/zoom.scss new file mode 100644 index 0000000000000..f76ea71e8d60c --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/transitions/zoom/zoom.scss @@ -0,0 +1,33 @@ +@keyframes zoomIn { + from { + opacity: 0; + transform: scale3d(.3, .3, .3); + } + + 50% { + opacity: 1; + } +} + +.zoomIn { + animation-name: 'zoomIn'; +} + +@keyframes zoomOut { + from { + opacity: 1; + } + + 50% { + opacity: 0; + transform: scale3d(.3, .3, .3); + } + + to { + opacity: 0; + } +} + +.zoomOut { + animation-name: 'zoomOut'; +} diff --git a/x-pack/legacy/plugins/canvas/server/plugin.ts b/x-pack/legacy/plugins/canvas/server/plugin.ts deleted file mode 100644 index 61cb81c91279a..0000000000000 --- a/x-pack/legacy/plugins/canvas/server/plugin.ts +++ /dev/null @@ -1,11 +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, PluginsSetup } from './shim'; - -export class Plugin { - public setup(core: CoreSetup, plugins: PluginsSetup) {} -} diff --git a/x-pack/legacy/plugins/canvas/server/shim.ts b/x-pack/legacy/plugins/canvas/server/shim.ts deleted file mode 100644 index c36ee3a291dae..0000000000000 --- a/x-pack/legacy/plugins/canvas/server/shim.ts +++ /dev/null @@ -1,63 +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 { ElasticsearchPlugin } from 'src/legacy/core_plugins/elasticsearch'; -import { Legacy } from 'kibana'; - -import { HomeServerPluginSetup } from 'src/plugins/home/server'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { PluginSetupContract } from '../../../../plugins/features/server'; - -export interface CoreSetup { - elasticsearch: ElasticsearchPlugin; - getInjectedUiAppVars: Legacy.Server['getInjectedUiAppVars']; - getServerConfig: Legacy.Server['config']; - http: { - route: Legacy.Server['route']; - }; -} - -export interface PluginsSetup { - features: PluginSetupContract; - home: HomeServerPluginSetup; - interpreter: { - register: (specs: any) => any; - }; - kibana: { - injectedUiAppVars: ReturnType<Legacy.Server['getInjectedUiAppVars']>; - }; - usageCollection: UsageCollectionSetup; -} - -export async function createSetupShim( - server: Legacy.Server -): Promise<{ coreSetup: CoreSetup; pluginsSetup: PluginsSetup }> { - const setup = server.newPlatform.setup.core; - return { - coreSetup: { - ...setup, - elasticsearch: server.plugins.elasticsearch, - getInjectedUiAppVars: server.getInjectedUiAppVars, - getServerConfig: () => server.config(), - http: { - // @ts-ignore: New Platform object not typed - ...server.newPlatform.setup.core.http, - route: (...args) => server.route(...args), - }, - }, - pluginsSetup: { - // @ts-ignore: New Platform not typed - features: server.newPlatform.setup.plugins.features, - home: server.newPlatform.setup.plugins.home, - // @ts-ignore Interpreter plugin not typed on legacy server - interpreter: server.plugins.interpreter, - kibana: { - injectedUiAppVars: await server.getInjectedUiAppVars('kibana'), - }, - usageCollection: server.newPlatform.setup.plugins.usageCollection, - }, - }; -} diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/autoplay_settings.tsx b/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/autoplay_settings.tsx index 628632a753693..1650cbad3a237 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/autoplay_settings.tsx +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/components/footer/settings/autoplay_settings.tsx @@ -13,7 +13,7 @@ import { } from '../../../context'; import { createTimeInterval } from '../../../../public/lib/time_interval'; // @ts-ignore Untyped local -import { CustomInterval } from '../../../../public/components/workpad_header/control_settings/custom_interval'; +import { CustomInterval } from '../../../../public/components/workpad_header/view_menu/custom_interval'; export type onSetAutoplayFn = (autoplay: boolean) => void; export type onSetIntervalFn = (interval: string) => void; diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/index.ts b/x-pack/legacy/plugins/canvas/shareable_runtime/index.ts index 5f1526d1bc240..2398a348f5a31 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/index.ts +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/index.ts @@ -6,7 +6,7 @@ export * from './api'; import '../../../../../built_assets/css/plugins/kibana/index.light.css'; -import '../../../../../built_assets/css/plugins/canvas/style/index.light.css'; +import '../public/style/index.scss'; import '@elastic/eui/dist/eui_theme_light.css'; import '@kbn/ui-framework/dist/kui_light.css'; diff --git a/x-pack/legacy/plugins/canvas/types/elements.ts b/x-pack/legacy/plugins/canvas/types/elements.ts index 86356f5bd32a9..5de6b4968545f 100644 --- a/x-pack/legacy/plugins/canvas/types/elements.ts +++ b/x-pack/legacy/plugins/canvas/types/elements.ts @@ -75,4 +75,8 @@ export interface ElementPosition { parent: string | null; } -export type PositionedElement = CanvasElement & { ast: ExpressionAstExpression }; +export type PositionedElement = CanvasElement & { + ast: ExpressionAstExpression; +} & { + position: ElementPosition; +}; diff --git a/x-pack/legacy/plugins/canvas/types/state.ts b/x-pack/legacy/plugins/canvas/types/state.ts index 13c8f7a9176ab..e9b580f81e668 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/infra/index.ts b/x-pack/legacy/plugins/infra/index.ts deleted file mode 100644 index 6ef273924a346..0000000000000 --- a/x-pack/legacy/plugins/infra/index.ts +++ /dev/null @@ -1,25 +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 { Root } from 'joi'; -import { savedObjectMappings } from '../../../plugins/infra/server'; - -export function infra(kibana: any) { - return new kibana.Plugin({ - id: 'infra', - configPrefix: 'xpack.infra', - require: ['kibana', 'elasticsearch'], - uiExports: { - mappings: savedObjectMappings, - }, - config(Joi: Root) { - return Joi.object({ - enabled: Joi.boolean().default(true), - }) - .unknown() - .default(); - }, - }); -} diff --git a/x-pack/legacy/plugins/maps/mappings.json b/x-pack/legacy/plugins/maps/mappings.json index 5e2e8c2c7e6e5..c939d096d7849 100644 --- a/x-pack/legacy/plugins/maps/mappings.json +++ b/x-pack/legacy/plugins/maps/mappings.json @@ -36,6 +36,12 @@ "indexPatternsWithGeoFieldCount": { "type": "long" }, + "indexPatternsWithGeoPointFieldCount": { + "type": "long" + }, + "indexPatternsWithGeoShapeFieldCount": { + "type": "long" + }, "mapsTotalCount": { "type": "long" }, diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js index 981a7f46e7c00..6131ff45c4a0f 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js @@ -20,6 +20,8 @@ describe('buildMapsTelemetry', () => { expect(result).toMatchObject({ indexPatternsWithGeoFieldCount: 0, + indexPatternsWithGeoPointFieldCount: 0, + indexPatternsWithGeoShapeFieldCount: 0, attributesPerMap: { dataSourcesCount: { avg: 0, @@ -45,7 +47,9 @@ describe('buildMapsTelemetry', () => { const result = buildMapsTelemetry({ mapSavedObjects, indexPatternSavedObjects, settings }); expect(result).toMatchObject({ - indexPatternsWithGeoFieldCount: 2, + indexPatternsWithGeoFieldCount: 3, + indexPatternsWithGeoPointFieldCount: 2, + indexPatternsWithGeoShapeFieldCount: 1, attributesPerMap: { dataSourcesCount: { avg: 2.6666666666666665, diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts index 4610baabad3fe..fe22c114cd921 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -61,13 +61,27 @@ function getIndexPatternsWithGeoFieldCount(indexPatterns: IIndexPattern[]) { ? JSON.parse(indexPattern.attributes.fields) : [] ); + const fieldListsWithGeoFields = fieldLists.filter(fields => fields.some( (field: IFieldType) => field.type === ES_GEO_FIELD_TYPE.GEO_POINT || field.type === ES_GEO_FIELD_TYPE.GEO_SHAPE ) ); - return fieldListsWithGeoFields.length; + + const fieldListsWithGeoPointFields = fieldLists.filter(fields => + fields.some((field: IFieldType) => field.type === ES_GEO_FIELD_TYPE.GEO_POINT) + ); + + const fieldListsWithGeoShapeFields = fieldLists.filter(fields => + fields.some((field: IFieldType) => field.type === ES_GEO_FIELD_TYPE.GEO_SHAPE) + ); + + return { + indexPatternsWithGeoFieldCount: fieldListsWithGeoFields.length, + indexPatternsWithGeoPointFieldCount: fieldListsWithGeoPointFields.length, + indexPatternsWithGeoShapeFieldCount: fieldListsWithGeoShapeFields.length, + }; } export function buildMapsTelemetry({ @@ -110,12 +124,16 @@ export function buildMapsTelemetry({ const dataSourcesCountSum = _.sum(dataSourcesCount); const layersCountSum = _.sum(layersCount); - const indexPatternsWithGeoFieldCount = getIndexPatternsWithGeoFieldCount( - indexPatternSavedObjects - ); + const { + indexPatternsWithGeoFieldCount, + indexPatternsWithGeoPointFieldCount, + indexPatternsWithGeoShapeFieldCount, + } = getIndexPatternsWithGeoFieldCount(indexPatternSavedObjects); return { settings, indexPatternsWithGeoFieldCount, + indexPatternsWithGeoPointFieldCount, + indexPatternsWithGeoShapeFieldCount, // Total count of maps mapsTotalCount: mapsCount, // Time of capture diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/test_resources/sample_index_pattern_saved_objects.json b/x-pack/legacy/plugins/maps/server/maps_telemetry/test_resources/sample_index_pattern_saved_objects.json index bb30a60f6d69f..0b36d5ff84016 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/test_resources/sample_index_pattern_saved_objects.json +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/test_resources/sample_index_pattern_saved_objects.json @@ -28,6 +28,20 @@ "updated_at": "2019-11-19T20:05:37.607Z", "version": "WzExMSwxXQ==" }, + { + "attributes": { + "fields": "[{\"name\":\"geometry\",\"type\":\"geo_point\",\"esTypes\":[\"geo_point\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", + "title": "indexpattern-with-geopoint2" + }, + "id": "55d572f0-0b07-11ea-9dd2-95afd7ad44d4", + "migrationVersion": { + "index-pattern": "7.6.0" + }, + "references": [], + "type": "index-pattern", + "updated_at": "2019-11-19T20:05:37.607Z", + "version": "WzExMSwxXQ==" + }, { "attributes": { "fields": "[{\"name\":\"assessment_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"date_exterior_condition\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"recording_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"sale_date\",\"type\":\"date\",\"esTypes\":[\"date\"],\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]", 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 88c22d01a527a..1006d36afa34d 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/legacy/plugins/monitoring/.agignore b/x-pack/legacy/plugins/monitoring/.agignore deleted file mode 100644 index 10fb4038cbdc2..0000000000000 --- a/x-pack/legacy/plugins/monitoring/.agignore +++ /dev/null @@ -1,3 +0,0 @@ -agent -node_modules -bower_components diff --git a/x-pack/legacy/plugins/monitoring/common/__tests__/format_timestamp_to_duration.js b/x-pack/legacy/plugins/monitoring/common/__tests__/format_timestamp_to_duration.js deleted file mode 100644 index 470d596bd2bdc..0000000000000 --- a/x-pack/legacy/plugins/monitoring/common/__tests__/format_timestamp_to_duration.js +++ /dev/null @@ -1,128 +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 expect from '@kbn/expect'; -import moment from 'moment'; -import { formatTimestampToDuration } from '../format_timestamp_to_duration'; -import { CALCULATE_DURATION_SINCE, CALCULATE_DURATION_UNTIL } from '../constants'; - -const testTime = moment('2010-05-01'); // pick a date where adding/subtracting 2 months formats roundly to '2 months 0 days' -const getTestTime = () => moment(testTime); // clones the obj so it's not mutated with .adds and .subtracts - -/** - * Test the moment-duration-format template - */ -describe('formatTimestampToDuration', () => { - describe('format timestamp to duration - time since', () => { - it('should format timestamp to human-readable duration', () => { - // time inputs are a few "moments" extra from the time advertised by name - const fiftyNineSeconds = getTestTime().subtract(59, 'seconds'); - expect( - formatTimestampToDuration(fiftyNineSeconds, CALCULATE_DURATION_SINCE, getTestTime()) - ).to.be('59 seconds'); - - const fiveMins = getTestTime() - .subtract(5, 'minutes') - .subtract(30, 'seconds'); - expect(formatTimestampToDuration(fiveMins, CALCULATE_DURATION_SINCE, getTestTime())).to.be( - '6 mins' - ); - - const sixHours = getTestTime() - .subtract(6, 'hours') - .subtract(30, 'minutes'); - expect(formatTimestampToDuration(sixHours, CALCULATE_DURATION_SINCE, getTestTime())).to.be( - '6 hrs 30 mins' - ); - - const sevenDays = getTestTime() - .subtract(7, 'days') - .subtract(6, 'hours') - .subtract(18, 'minutes'); - expect(formatTimestampToDuration(sevenDays, CALCULATE_DURATION_SINCE, getTestTime())).to.be( - '7 days 6 hrs 18 mins' - ); - - const eightWeeks = getTestTime() - .subtract(8, 'weeks') - .subtract(7, 'days') - .subtract(6, 'hours') - .subtract(18, 'minutes'); - expect(formatTimestampToDuration(eightWeeks, CALCULATE_DURATION_SINCE, getTestTime())).to.be( - '2 months 2 days' - ); - - const oneHour = getTestTime().subtract(1, 'hour'); // should trim 0 min - expect(formatTimestampToDuration(oneHour, CALCULATE_DURATION_SINCE, getTestTime())).to.be( - '1 hr' - ); - - const oneDay = getTestTime().subtract(1, 'day'); // should trim 0 hrs - expect(formatTimestampToDuration(oneDay, CALCULATE_DURATION_SINCE, getTestTime())).to.be( - '1 day' - ); - - const twoMonths = getTestTime().subtract(2, 'month'); // should trim 0 days - expect(formatTimestampToDuration(twoMonths, CALCULATE_DURATION_SINCE, getTestTime())).to.be( - '2 months' - ); - }); - }); - - describe('format timestamp to duration - time until', () => { - it('should format timestamp to human-readable duration', () => { - // time inputs are a few "moments" extra from the time advertised by name - const fiftyNineSeconds = getTestTime().add(59, 'seconds'); - expect( - formatTimestampToDuration(fiftyNineSeconds, CALCULATE_DURATION_UNTIL, getTestTime()) - ).to.be('59 seconds'); - - const fiveMins = getTestTime().add(10, 'minutes'); - expect(formatTimestampToDuration(fiveMins, CALCULATE_DURATION_UNTIL, getTestTime())).to.be( - '10 mins' - ); - - const sixHours = getTestTime() - .add(6, 'hours') - .add(30, 'minutes'); - expect(formatTimestampToDuration(sixHours, CALCULATE_DURATION_UNTIL, getTestTime())).to.be( - '6 hrs 30 mins' - ); - - const sevenDays = getTestTime() - .add(7, 'days') - .add(6, 'hours') - .add(18, 'minutes'); - expect(formatTimestampToDuration(sevenDays, CALCULATE_DURATION_UNTIL, getTestTime())).to.be( - '7 days 6 hrs 18 mins' - ); - - const eightWeeks = getTestTime() - .add(8, 'weeks') - .add(7, 'days') - .add(6, 'hours') - .add(18, 'minutes'); - expect(formatTimestampToDuration(eightWeeks, CALCULATE_DURATION_UNTIL, getTestTime())).to.be( - '2 months 2 days' - ); - - const oneHour = getTestTime().add(1, 'hour'); // should trim 0 min - expect(formatTimestampToDuration(oneHour, CALCULATE_DURATION_UNTIL, getTestTime())).to.be( - '1 hr' - ); - - const oneDay = getTestTime().add(1, 'day'); // should trim 0 hrs - expect(formatTimestampToDuration(oneDay, CALCULATE_DURATION_UNTIL, getTestTime())).to.be( - '1 day' - ); - - const twoMonths = getTestTime().add(2, 'month'); // should trim 0 days - expect(formatTimestampToDuration(twoMonths, CALCULATE_DURATION_UNTIL, getTestTime())).to.be( - '2 months' - ); - }); - }); -}); diff --git a/x-pack/legacy/plugins/monitoring/common/cancel_promise.ts b/x-pack/legacy/plugins/monitoring/common/cancel_promise.ts deleted file mode 100644 index f100edda50796..0000000000000 --- a/x-pack/legacy/plugins/monitoring/common/cancel_promise.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export enum Status { - Canceled, - Failed, - Resolved, - Awaiting, - Idle, -} - -/** - * Simple [PromiseWithCancel] factory - */ -export class PromiseWithCancel { - private _promise: Promise<any>; - private _status: Status = Status.Idle; - - /** - * @param {Promise} promise Promise you want to cancel / track - */ - constructor(promise: Promise<any>) { - this._promise = promise; - } - - /** - * Cancel the promise in any state - */ - public cancel = (): void => { - this._status = Status.Canceled; - }; - - /** - * @returns status based on [Status] - */ - public status = (): Status => { - return this._status; - }; - - /** - * @returns promise passed in [constructor] - * This sets the state to Status.Awaiting - */ - public promise = (): Promise<any> => { - if (this._status === Status.Canceled) { - throw Error('Getting a canceled promise is not allowed'); - } else if (this._status !== Status.Idle) { - return this._promise; - } - return new Promise((resolve, reject) => { - this._status = Status.Awaiting; - return this._promise - .then(response => { - if (this._status !== Status.Canceled) { - this._status = Status.Resolved; - return resolve(response); - } - }) - .catch(error => { - if (this._status !== Status.Canceled) { - this._status = Status.Failed; - return reject(error); - } - }); - }); - }; -} diff --git a/x-pack/legacy/plugins/monitoring/common/constants.ts b/x-pack/legacy/plugins/monitoring/common/constants.ts deleted file mode 100644 index 36030e1fa7f2a..0000000000000 --- a/x-pack/legacy/plugins/monitoring/common/constants.ts +++ /dev/null @@ -1,268 +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. - */ - -/** - * Helper string to add as a tag in every logging call - */ -export const LOGGING_TAG = 'monitoring'; -/** - * Helper string to add as a tag in every logging call related to Kibana monitoring - */ -export const KIBANA_MONITORING_LOGGING_TAG = 'kibana-monitoring'; - -/** - * The Monitoring API version is the expected API format that we export and expect to import. - * @type {string} - */ -export const MONITORING_SYSTEM_API_VERSION = '7'; -/** - * The type name used within the Monitoring index to publish Kibana ops stats. - * @type {string} - */ -export const KIBANA_STATS_TYPE_MONITORING = 'kibana_stats'; // similar to KIBANA_STATS_TYPE but rolled up into 10s stats from 5s intervals through ops_buffer -/** - * The type name used within the Monitoring index to publish Kibana stats. - * @type {string} - */ -export const KIBANA_SETTINGS_TYPE = 'kibana_settings'; -/** - * The type name used within the Monitoring index to publish Kibana usage stats. - * NOTE: this string shows as-is in the stats API as a field name for the kibana usage stats - * @type {string} - */ -export const KIBANA_USAGE_TYPE = 'kibana'; - -/* - * Key for the localStorage service - */ -export const STORAGE_KEY = 'xpack.monitoring.data'; - -/** - * Units for derivative metric values - */ -export const NORMALIZED_DERIVATIVE_UNIT = '1s'; - -/* - * Values for column sorting in table options - * @type {number} 1 or -1 - */ -export const EUI_SORT_ASCENDING = 'asc'; -export const EUI_SORT_DESCENDING = 'desc'; -export const SORT_ASCENDING = 1; -export const SORT_DESCENDING = -1; - -/* - * Chart colors - * @type {string} - */ -export const CHART_LINE_COLOR = '#d2d2d2'; -export const CHART_TEXT_COLOR = '#9c9c9c'; - -/* - * Number of cluster alerts to show on overview page - * @type {number} - */ -export const CLUSTER_ALERTS_SEARCH_SIZE = 3; - -/* - * Format for moment-duration-format timestamp-to-duration template if the time diffs are gte 1 month - * @type {string} - */ -export const FORMAT_DURATION_TEMPLATE_LONG = 'M [months] d [days]'; - -/* - * Format for moment-duration-format timestamp-to-duration template if the time diffs are lt 1 month but gt 1 minute - * @type {string} - */ -export const FORMAT_DURATION_TEMPLATE_SHORT = ' d [days] h [hrs] m [min]'; - -/* - * Format for moment-duration-format timestamp-to-duration template if the time diffs are lt 1 minute - * @type {string} - */ -export const FORMAT_DURATION_TEMPLATE_TINY = ' s [seconds]'; - -/* - * Simple unique values for Timestamp to duration flags. These are used for - * determining if calculation should be formatted as "time until" (now to - * timestamp) or "time since" (timestamp to now) - */ -export const CALCULATE_DURATION_SINCE = 'since'; -export const CALCULATE_DURATION_UNTIL = 'until'; - -/** - * In order to show ML Jobs tab in the Elasticsearch section / tab navigation, license must be supported - */ -export const ML_SUPPORTED_LICENSES = ['trial', 'platinum', 'enterprise']; - -/** - * Metadata service URLs for the different cloud services that have constant URLs (e.g., unlike GCP, which is a constant prefix). - * - * @type {Object} - */ -export const CLOUD_METADATA_SERVICES = { - // We explicitly call out the version, 2016-09-02, rather than 'latest' to avoid unexpected changes - AWS_URL: 'http://169.254.169.254/2016-09-02/dynamic/instance-identity/document', - - // 2017-04-02 is the first GA release of this API - AZURE_URL: 'http://169.254.169.254/metadata/instance?api-version=2017-04-02', - - // GCP documentation shows both 'metadata.google.internal' (mostly) and '169.254.169.254' (sometimes) - // To bypass potential DNS changes, the IP was used because it's shared with other cloud services - GCP_URL_PREFIX: 'http://169.254.169.254/computeMetadata/v1/instance', -}; - -/** - * Constants used by Logstash monitoring code - */ -export const LOGSTASH = { - MAJOR_VER_REQD_FOR_PIPELINES: 6, - - /* - * Names ES keys on for different Logstash pipeline queues. - * @type {string} - */ - QUEUE_TYPES: { - MEMORY: 'memory', - PERSISTED: 'persisted', - }, -}; - -export const DEBOUNCE_SLOW_MS = 17; // roughly how long it takes to render a frame at 60fps -export const DEBOUNCE_FAST_MS = 10; // roughly how long it takes to render a frame at 100fps - -/** - * Configuration key for setting the email address used for cluster alert notifications. - */ -export const CLUSTER_ALERTS_ADDRESS_CONFIG_KEY = 'cluster_alerts.email_notifications.email_address'; - -export const STANDALONE_CLUSTER_CLUSTER_UUID = '__standalone_cluster__'; - -export const INDEX_PATTERN = '.monitoring-*-6-*,.monitoring-*-7-*'; -export const INDEX_PATTERN_KIBANA = '.monitoring-kibana-6-*,.monitoring-kibana-7-*'; -export const INDEX_PATTERN_LOGSTASH = '.monitoring-logstash-6-*,.monitoring-logstash-7-*'; -export const INDEX_PATTERN_BEATS = '.monitoring-beats-6-*,.monitoring-beats-7-*'; -export const INDEX_ALERTS = '.monitoring-alerts-6,.monitoring-alerts-7'; -export const INDEX_PATTERN_ELASTICSEARCH = '.monitoring-es-6-*,.monitoring-es-7-*'; - -// This is the unique token that exists in monitoring indices collected by metricbeat -export const METRICBEAT_INDEX_NAME_UNIQUE_TOKEN = '-mb-'; - -// We use this for metricbeat migration to identify specific products that we do not have constants for -export const ELASTICSEARCH_SYSTEM_ID = 'elasticsearch'; - -/** - * The id of the infra source owned by the monitoring plugin. - */ -export const INFRA_SOURCE_ID = 'internal-stack-monitoring'; - -/* - * These constants represent code paths within `getClustersFromRequest` - * that an api call wants to invoke. This is meant as an optimization to - * avoid unnecessary ES queries (looking at you logstash) when the data - * is not used. In the long term, it'd be nice to have separate api calls - * instead of this path logic. - */ -export const CODE_PATH_ALL = 'all'; -export const CODE_PATH_ALERTS = 'alerts'; -export const CODE_PATH_KIBANA = 'kibana'; -export const CODE_PATH_ELASTICSEARCH = 'elasticsearch'; -export const CODE_PATH_ML = 'ml'; -export const CODE_PATH_BEATS = 'beats'; -export const CODE_PATH_LOGSTASH = 'logstash'; -export const CODE_PATH_APM = 'apm'; -export const CODE_PATH_LICENSE = 'license'; -export const CODE_PATH_LOGS = 'logs'; - -/** - * The header sent by telemetry service when hitting Elasticsearch to identify query source - * @type {string} - */ -export const TELEMETRY_QUERY_SOURCE = 'TELEMETRY'; - -/** - * The name of the Kibana System ID used to publish and look up Kibana stats through the Monitoring system. - * @type {string} - */ -export const KIBANA_SYSTEM_ID = 'kibana'; - -/** - * The name of the Beats System ID used to publish and look up Beats stats through the Monitoring system. - * @type {string} - */ -export const BEATS_SYSTEM_ID = 'beats'; - -/** - * The name of the Apm System ID used to publish and look up Apm stats through the Monitoring system. - * @type {string} - */ -export const APM_SYSTEM_ID = 'apm'; - -/** - * The name of the Kibana System ID used to look up Logstash stats through the Monitoring system. - * @type {string} - */ -export const LOGSTASH_SYSTEM_ID = 'logstash'; - -/** - * The name of the Kibana System ID used to look up Reporting stats through the Monitoring system. - * @type {string} - */ -export const REPORTING_SYSTEM_ID = 'reporting'; - -/** - * The amount of time, in milliseconds, to wait between collecting kibana stats from es. - * - * Currently 24 hours kept in sync with reporting interval. - * @type {Number} - */ -export const TELEMETRY_COLLECTION_INTERVAL = 86400000; - -/** - * We want to slowly rollout the migration from watcher-based cluster alerts to - * kibana alerts and we only want to enable the kibana alerts once all - * watcher-based cluster alerts have been migrated so this flag will serve - * as the only way to see the new UI and actually run Kibana alerts. It will - * be false until all alerts have been migrated, then it will be removed - */ -export const KIBANA_ALERTING_ENABLED = false; - -/** - * The prefix for all alert types used by monitoring - */ -export const ALERT_TYPE_PREFIX = 'monitoring_'; - -/** - * This is the alert type id for the license expiration alert - */ -export const ALERT_TYPE_LICENSE_EXPIRATION = `${ALERT_TYPE_PREFIX}alert_type_license_expiration`; -/** - * This is the alert type id for the cluster state alert - */ -export const ALERT_TYPE_CLUSTER_STATE = `${ALERT_TYPE_PREFIX}alert_type_cluster_state`; - -/** - * A listing of all alert types - */ -export const ALERT_TYPES = [ALERT_TYPE_LICENSE_EXPIRATION, ALERT_TYPE_CLUSTER_STATE]; - -/** - * Matches the id for the built-in in email action type - * See x-pack/plugins/actions/server/builtin_action_types/email.ts - */ -export const ALERT_ACTION_TYPE_EMAIL = '.email'; - -/** - * The number of alerts that have been migrated - */ -export const NUMBER_OF_MIGRATED_ALERTS = 2; - -/** - * The advanced settings config name for the email address - */ -export const MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS = 'monitoring:alertingEmailAddress'; - -export const ALERT_EMAIL_SERVICES = ['gmail', 'hotmail', 'icloud', 'outlook365', 'ses', 'yahoo']; diff --git a/x-pack/legacy/plugins/monitoring/common/format_timestamp_to_duration.js b/x-pack/legacy/plugins/monitoring/common/format_timestamp_to_duration.js deleted file mode 100644 index 46c8f7db49b0f..0000000000000 --- a/x-pack/legacy/plugins/monitoring/common/format_timestamp_to_duration.js +++ /dev/null @@ -1,54 +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 moment from 'moment'; -import 'moment-duration-format'; -import { - FORMAT_DURATION_TEMPLATE_TINY, - FORMAT_DURATION_TEMPLATE_SHORT, - FORMAT_DURATION_TEMPLATE_LONG, - CALCULATE_DURATION_SINCE, - CALCULATE_DURATION_UNTIL, -} from './constants'; - -/* - * Formats a timestamp string - * @param timestamp: ISO time string - * @param calculationFlag: control "since" or "until" logic - * @param initialTime {Object} moment object (not required) - * @return string - */ -export function formatTimestampToDuration(timestamp, calculationFlag, initialTime) { - initialTime = initialTime || moment(); - let timeDuration; - if (calculationFlag === CALCULATE_DURATION_SINCE) { - timeDuration = moment.duration(initialTime - moment(timestamp)); // since: now - timestamp - } else if (calculationFlag === CALCULATE_DURATION_UNTIL) { - timeDuration = moment.duration(moment(timestamp) - initialTime); // until: timestamp - now - } else { - throw new Error( - '[formatTimestampToDuration] requires a [calculationFlag] parameter to specify format as "since" or "until" the given time.' - ); - } - - // See https://github.com/elastic/x-pack-kibana/issues/3554 - let duration; - if (Math.abs(initialTime.diff(timestamp, 'months')) >= 1) { - // time diff is greater than 1 month, show months / days - duration = moment.duration(timeDuration).format(FORMAT_DURATION_TEMPLATE_LONG); - } else if (Math.abs(initialTime.diff(timestamp, 'minutes')) >= 1) { - // time diff is less than 1 month but greater than a minute, show days / hours / minutes - duration = moment.duration(timeDuration).format(FORMAT_DURATION_TEMPLATE_SHORT); - } else { - // time diff is less than a minute, show seconds - duration = moment.duration(timeDuration).format(FORMAT_DURATION_TEMPLATE_TINY); - } - - return duration - .replace(/ 0 mins$/, '') - .replace(/ 0 hrs$/, '') - .replace(/ 0 days$/, ''); // See https://github.com/jsmreese/moment-duration-format/issues/64 -} diff --git a/x-pack/legacy/plugins/monitoring/common/formatting.js b/x-pack/legacy/plugins/monitoring/common/formatting.js deleted file mode 100644 index ed5d68f942dfd..0000000000000 --- a/x-pack/legacy/plugins/monitoring/common/formatting.js +++ /dev/null @@ -1,35 +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 moment from 'moment-timezone'; - -export const LARGE_FLOAT = '0,0.[00]'; -export const SMALL_FLOAT = '0.[00]'; -export const LARGE_BYTES = '0,0.0 b'; -export const SMALL_BYTES = '0.0 b'; -export const LARGE_ABBREVIATED = '0,0.[0]a'; - -/** - * Format the {@code date} in the user's expected date/time format using their <em>dateFormat:tz</em> defined time zone. - * @param date Either a numeric Unix timestamp or a {@code Date} object - * @returns The date formatted using 'LL LTS' - */ -export function formatDateTimeLocal(date, timezone) { - if (timezone === 'Browser') { - timezone = moment.tz.guess() || 'utc'; - } - - return moment.tz(date, timezone).format('LL LTS'); -} - -/** - * Shorten a Logstash Pipeline's hash for display purposes - * @param {string} hash The complete hash - * @return {string} The shortened hash - */ -export function shortenPipelineHash(hash) { - return hash.substr(0, 6); -} diff --git a/x-pack/legacy/plugins/monitoring/common/index.js b/x-pack/legacy/plugins/monitoring/common/index.js deleted file mode 100644 index 183396f8f0d72..0000000000000 --- a/x-pack/legacy/plugins/monitoring/common/index.js +++ /dev/null @@ -1,7 +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. - */ - -export { formatTimestampToDuration } from './format_timestamp_to_duration'; diff --git a/x-pack/legacy/plugins/monitoring/config.js b/x-pack/legacy/plugins/monitoring/config.js deleted file mode 100644 index fd4e6512c5063..0000000000000 --- a/x-pack/legacy/plugins/monitoring/config.js +++ /dev/null @@ -1,147 +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. - */ - -/** - * User-configurable settings for xpack.monitoring via configuration schema - * @param {Object} Joi - HapiJS Joi module that allows for schema validation - * @return {Object} config schema - */ -export const config = Joi => { - const DEFAULT_REQUEST_HEADERS = ['authorization']; - - return Joi.object({ - enabled: Joi.boolean().default(true), - ui: Joi.object({ - enabled: Joi.boolean().default(true), - logs: Joi.object({ - index: Joi.string().default('filebeat-*'), - }).default(), - ccs: Joi.object({ - enabled: Joi.boolean().default(true), - }).default(), - container: Joi.object({ - elasticsearch: Joi.object({ - enabled: Joi.boolean().default(false), - }).default(), - logstash: Joi.object({ - enabled: Joi.boolean().default(false), - }).default(), - }).default(), - max_bucket_size: Joi.number().default(10000), - min_interval_seconds: Joi.number().default(10), - show_license_expiration: Joi.boolean().default(true), - elasticsearch: Joi.object({ - customHeaders: Joi.object().default({}), - logQueries: Joi.boolean().default(false), - requestHeadersWhitelist: Joi.array() - .items() - .single() - .default(DEFAULT_REQUEST_HEADERS), - sniffOnStart: Joi.boolean().default(false), - sniffInterval: Joi.number() - .allow(false) - .default(false), - sniffOnConnectionFault: Joi.boolean().default(false), - hosts: Joi.array() - .items(Joi.string().uri({ scheme: ['http', 'https'] })) - .single(), // if empty, use Kibana's connection config - username: Joi.string(), - password: Joi.string(), - requestTimeout: Joi.number().default(30000), - pingTimeout: Joi.number().default(30000), - ssl: Joi.object({ - verificationMode: Joi.string() - .valid('none', 'certificate', 'full') - .default('full'), - certificateAuthorities: Joi.array() - .single() - .items(Joi.string()), - certificate: Joi.string(), - key: Joi.string(), - keyPassphrase: Joi.string(), - keystore: Joi.object({ - path: Joi.string(), - password: Joi.string(), - }).default(), - truststore: Joi.object({ - path: Joi.string(), - password: Joi.string(), - }).default(), - alwaysPresentCertificate: Joi.boolean().default(false), - }).default(), - apiVersion: Joi.string().default('master'), - logFetchCount: Joi.number().default(10), - }).default(), - }).default(), - kibana: Joi.object({ - collection: Joi.object({ - enabled: Joi.boolean().default(true), - interval: Joi.number().default(10000), // op status metrics get buffered at `ops.interval` and flushed to the bulk endpoint at this interval - }).default(), - }).default(), - elasticsearch: Joi.object({ - customHeaders: Joi.object().default({}), - logQueries: Joi.boolean().default(false), - requestHeadersWhitelist: Joi.array() - .items() - .single() - .default(DEFAULT_REQUEST_HEADERS), - sniffOnStart: Joi.boolean().default(false), - sniffInterval: Joi.number() - .allow(false) - .default(false), - sniffOnConnectionFault: Joi.boolean().default(false), - hosts: Joi.array() - .items(Joi.string().uri({ scheme: ['http', 'https'] })) - .single(), // if empty, use Kibana's connection config - username: Joi.string(), - password: Joi.string(), - requestTimeout: Joi.number().default(30000), - pingTimeout: Joi.number().default(30000), - ssl: Joi.object({ - verificationMode: Joi.string() - .valid('none', 'certificate', 'full') - .default('full'), - certificateAuthorities: Joi.array() - .single() - .items(Joi.string()), - certificate: Joi.string(), - key: Joi.string(), - keyPassphrase: Joi.string(), - keystore: Joi.object({ - path: Joi.string(), - password: Joi.string(), - }).default(), - truststore: Joi.object({ - path: Joi.string(), - password: Joi.string(), - }).default(), - alwaysPresentCertificate: Joi.boolean().default(false), - }).default(), - apiVersion: Joi.string().default('master'), - }).default(), - cluster_alerts: Joi.object({ - enabled: Joi.boolean().default(true), - email_notifications: Joi.object({ - enabled: Joi.boolean().default(true), - email_address: Joi.string().email(), - }).default(), - }).default(), - licensing: Joi.object({ - api_polling_frequency: Joi.number().default(30001), - }), - agent: Joi.object({ - interval: Joi.string() - .regex(/[\d\.]+[yMwdhms]/) - .default('10s'), - }).default(), - tests: Joi.object({ - cloud_detector: Joi.object({ - enabled: Joi.boolean().default(true), - }).default(), - }).default(), - }).default(); -}; diff --git a/x-pack/legacy/plugins/monitoring/config.ts b/x-pack/legacy/plugins/monitoring/config.ts new file mode 100644 index 0000000000000..0c664fbe1c00c --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/config.ts @@ -0,0 +1,147 @@ +/* + * 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. + */ + +/** + * User-configurable settings for xpack.monitoring via configuration schema + * @param {Object} Joi - HapiJS Joi module that allows for schema validation + * @return {Object} config schema + */ +export const config = (Joi: any) => { + const DEFAULT_REQUEST_HEADERS = ['authorization']; + + return Joi.object({ + enabled: Joi.boolean().default(true), + ui: Joi.object({ + enabled: Joi.boolean().default(true), + logs: Joi.object({ + index: Joi.string().default('filebeat-*'), + }).default(), + ccs: Joi.object({ + enabled: Joi.boolean().default(true), + }).default(), + container: Joi.object({ + elasticsearch: Joi.object({ + enabled: Joi.boolean().default(false), + }).default(), + logstash: Joi.object({ + enabled: Joi.boolean().default(false), + }).default(), + }).default(), + max_bucket_size: Joi.number().default(10000), + min_interval_seconds: Joi.number().default(10), + show_license_expiration: Joi.boolean().default(true), + elasticsearch: Joi.object({ + customHeaders: Joi.object().default({}), + logQueries: Joi.boolean().default(false), + requestHeadersWhitelist: Joi.array() + .items() + .single() + .default(DEFAULT_REQUEST_HEADERS), + sniffOnStart: Joi.boolean().default(false), + sniffInterval: Joi.number() + .allow(false) + .default(false), + sniffOnConnectionFault: Joi.boolean().default(false), + hosts: Joi.array() + .items(Joi.string().uri({ scheme: ['http', 'https'] })) + .single(), // if empty, use Kibana's connection config + username: Joi.string(), + password: Joi.string(), + requestTimeout: Joi.number().default(30000), + pingTimeout: Joi.number().default(30000), + ssl: Joi.object({ + verificationMode: Joi.string() + .valid('none', 'certificate', 'full') + .default('full'), + certificateAuthorities: Joi.array() + .single() + .items(Joi.string()), + certificate: Joi.string(), + key: Joi.string(), + keyPassphrase: Joi.string(), + keystore: Joi.object({ + path: Joi.string(), + password: Joi.string(), + }).default(), + truststore: Joi.object({ + path: Joi.string(), + password: Joi.string(), + }).default(), + alwaysPresentCertificate: Joi.boolean().default(false), + }).default(), + apiVersion: Joi.string().default('master'), + logFetchCount: Joi.number().default(10), + }).default(), + }).default(), + kibana: Joi.object({ + collection: Joi.object({ + enabled: Joi.boolean().default(true), + interval: Joi.number().default(10000), // op status metrics get buffered at `ops.interval` and flushed to the bulk endpoint at this interval + }).default(), + }).default(), + elasticsearch: Joi.object({ + customHeaders: Joi.object().default({}), + logQueries: Joi.boolean().default(false), + requestHeadersWhitelist: Joi.array() + .items() + .single() + .default(DEFAULT_REQUEST_HEADERS), + sniffOnStart: Joi.boolean().default(false), + sniffInterval: Joi.number() + .allow(false) + .default(false), + sniffOnConnectionFault: Joi.boolean().default(false), + hosts: Joi.array() + .items(Joi.string().uri({ scheme: ['http', 'https'] })) + .single(), // if empty, use Kibana's connection config + username: Joi.string(), + password: Joi.string(), + requestTimeout: Joi.number().default(30000), + pingTimeout: Joi.number().default(30000), + ssl: Joi.object({ + verificationMode: Joi.string() + .valid('none', 'certificate', 'full') + .default('full'), + certificateAuthorities: Joi.array() + .single() + .items(Joi.string()), + certificate: Joi.string(), + key: Joi.string(), + keyPassphrase: Joi.string(), + keystore: Joi.object({ + path: Joi.string(), + password: Joi.string(), + }).default(), + truststore: Joi.object({ + path: Joi.string(), + password: Joi.string(), + }).default(), + alwaysPresentCertificate: Joi.boolean().default(false), + }).default(), + apiVersion: Joi.string().default('master'), + }).default(), + cluster_alerts: Joi.object({ + enabled: Joi.boolean().default(true), + email_notifications: Joi.object({ + enabled: Joi.boolean().default(true), + email_address: Joi.string().email(), + }).default(), + }).default(), + licensing: Joi.object({ + api_polling_frequency: Joi.number().default(30001), + }), + agent: Joi.object({ + interval: Joi.string() + .regex(/[\d\.]+[yMwdhms]/) + .default('10s'), + }).default(), + tests: Joi.object({ + cloud_detector: Joi.object({ + enabled: Joi.boolean().default(true), + }).default(), + }).default(), + }).default(); +}; diff --git a/x-pack/legacy/plugins/monitoring/index.js b/x-pack/legacy/plugins/monitoring/index.js deleted file mode 100644 index ccb45dc1f446f..0000000000000 --- a/x-pack/legacy/plugins/monitoring/index.js +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { get } from 'lodash'; -import { resolve } from 'path'; -import { config } from './config'; -import { getUiExports } from './ui_exports'; -import { KIBANA_ALERTING_ENABLED } from './common/constants'; - -/** - * Invokes plugin modules to instantiate the Monitoring plugin for Kibana - * @param kibana {Object} Kibana plugin instance - * @return {Object} Monitoring UI Kibana plugin object - */ -const deps = ['kibana', 'elasticsearch', 'xpack_main']; -if (KIBANA_ALERTING_ENABLED) { - deps.push(...['alerting', 'actions']); -} -export const monitoring = kibana => { - return new kibana.Plugin({ - require: deps, - id: 'monitoring', - configPrefix: 'monitoring', - publicDir: resolve(__dirname, 'public'), - init(server) { - const serverConfig = server.config(); - const npMonitoring = server.newPlatform.setup.plugins.monitoring; - if (npMonitoring) { - const kbnServerStatus = this.kbnServer.status; - npMonitoring.registerLegacyAPI({ - getServerStatus: () => { - const status = kbnServerStatus.toJSON(); - return get(status, 'overall.state'); - }, - }); - } - - server.injectUiAppVars('monitoring', () => { - return { - maxBucketSize: serverConfig.get('monitoring.ui.max_bucket_size'), - minIntervalSeconds: serverConfig.get('monitoring.ui.min_interval_seconds'), - kbnIndex: serverConfig.get('kibana.index'), - showLicenseExpiration: serverConfig.get('monitoring.ui.show_license_expiration'), - showCgroupMetricsElasticsearch: serverConfig.get( - 'monitoring.ui.container.elasticsearch.enabled' - ), - showCgroupMetricsLogstash: serverConfig.get('monitoring.ui.container.logstash.enabled'), // Note, not currently used, but see https://github.com/elastic/x-pack-kibana/issues/1559 part 2 - }; - }); - }, - config, - uiExports: getUiExports(), - }); -}; diff --git a/x-pack/legacy/plugins/monitoring/index.ts b/x-pack/legacy/plugins/monitoring/index.ts new file mode 100644 index 0000000000000..1a0fecb9ef5b5 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/index.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 Hapi from 'hapi'; +import { config } from './config'; +import { KIBANA_ALERTING_ENABLED } from '../../../plugins/monitoring/common/constants'; + +/** + * Invokes plugin modules to instantiate the Monitoring plugin for Kibana + * @param kibana {Object} Kibana plugin instance + * @return {Object} Monitoring UI Kibana plugin object + */ +const deps = ['kibana', 'elasticsearch', 'xpack_main']; +if (KIBANA_ALERTING_ENABLED) { + deps.push(...['alerting', 'actions']); +} +export const monitoring = (kibana: any) => { + return new kibana.Plugin({ + require: deps, + id: 'monitoring', + configPrefix: 'monitoring', + init(server: Hapi.Server) { + const npMonitoring = server.newPlatform.setup.plugins.monitoring as object & { + registerLegacyAPI: (api: unknown) => void; + }; + if (npMonitoring) { + const kbnServerStatus = this.kbnServer.status; + npMonitoring.registerLegacyAPI({ + getServerStatus: () => { + const status = kbnServerStatus.toJSON(); + return status?.overall?.state; + }, + }); + } + }, + config, + }); +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/apm/status_icon.js b/x-pack/legacy/plugins/monitoring/public/components/apm/status_icon.js deleted file mode 100644 index 2de77b70df646..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/apm/status_icon.js +++ /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 React from 'react'; -import { StatusIcon } from 'plugins/monitoring/components/status_icon'; -import { i18n } from '@kbn/i18n'; - -export function ApmStatusIcon({ status, availability = true }) { - const type = (() => { - if (!availability) { - return StatusIcon.TYPES.GRAY; - } - - const statusKey = status.toUpperCase(); - return StatusIcon.TYPES[statusKey] || StatusIcon.TYPES.YELLOW; - })(); - - return ( - <StatusIcon - type={type} - label={i18n.translate('xpack.monitoring.apm.healthStatusLabel', { - defaultMessage: 'Health: {status}', - values: { - status, - }, - })} - /> - ); -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js b/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js deleted file mode 100644 index a20728eb9a58f..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/beats/listing/listing.js +++ /dev/null @@ -1,204 +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, { PureComponent } from 'react'; -import { uniq, get } from 'lodash'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiSpacer, - EuiLink, - EuiScreenReaderOnly, -} from '@elastic/eui'; -import { Stats } from 'plugins/monitoring/components/beats'; -import { formatMetric } from 'plugins/monitoring/lib/format_number'; -import { EuiMonitoringTable } from 'plugins/monitoring/components/table'; -import { i18n } from '@kbn/i18n'; -import { BEATS_SYSTEM_ID } from '../../../../common/constants'; -import { getSafeForExternalLink } from '../../../lib/get_safe_for_external_link'; -import { ListingCallOut } from '../../setup_mode/listing_callout'; -import { SetupModeBadge } from '../../setup_mode/badge'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export class Listing extends PureComponent { - getColumns() { - const setupMode = this.props.setupMode; - - return [ - { - name: i18n.translate('xpack.monitoring.beats.instances.nameTitle', { - defaultMessage: 'Name', - }), - field: 'name', - render: (name, beat) => { - let setupModeStatus = null; - if (setupMode && setupMode.enabled) { - const list = get(setupMode, 'data.byUuid', {}); - const status = list[beat.uuid] || {}; - const instance = { - uuid: beat.uuid, - name: beat.name, - }; - - setupModeStatus = ( - <div className="monTableCell__setupModeStatus"> - <SetupModeBadge - setupMode={setupMode} - status={status} - instance={instance} - productName={BEATS_SYSTEM_ID} - /> - </div> - ); - } - - return ( - <div> - <EuiLink - href={getSafeForExternalLink(`#/beats/beat/${beat.uuid}`)} - data-test-subj={`beatLink-${name}`} - > - {name} - </EuiLink> - {setupModeStatus} - </div> - ); - }, - }, - { - name: i18n.translate('xpack.monitoring.beats.instances.typeTitle', { - defaultMessage: 'Type', - }), - field: 'type', - }, - { - name: i18n.translate('xpack.monitoring.beats.instances.outputEnabledTitle', { - defaultMessage: 'Output Enabled', - }), - field: 'output', - }, - { - name: i18n.translate('xpack.monitoring.beats.instances.totalEventsRateTitle', { - defaultMessage: 'Total Events Rate', - }), - field: 'total_events_rate', - render: value => formatMetric(value, '', '/s'), - }, - { - name: i18n.translate('xpack.monitoring.beats.instances.bytesSentRateTitle', { - defaultMessage: 'Bytes Sent Rate', - }), - field: 'bytes_sent_rate', - render: value => formatMetric(value, 'byte', '/s'), - }, - { - name: i18n.translate('xpack.monitoring.beats.instances.outputErrorsTitle', { - defaultMessage: 'Output Errors', - }), - field: 'errors', - render: value => formatMetric(value, '0'), - }, - { - name: i18n.translate('xpack.monitoring.beats.instances.allocatedMemoryTitle', { - defaultMessage: 'Allocated Memory', - }), - field: 'memory', - render: value => formatMetric(value, 'byte'), - }, - { - name: i18n.translate('xpack.monitoring.beats.instances.versionTitle', { - defaultMessage: 'Version', - }), - field: 'version', - }, - ]; - } - - render() { - const { stats, data, sorting, pagination, onTableChange, setupMode } = this.props; - - let setupModeCallOut = null; - if (setupMode.enabled && setupMode.data) { - setupModeCallOut = ( - <ListingCallOut - setupModeData={setupMode.data} - useNodeIdentifier={false} - productName={BEATS_SYSTEM_ID} - /> - ); - } - - const types = uniq(data.map(item => item.type)).map(type => { - return { value: type }; - }); - - const versions = uniq(data.map(item => item.version)).map(version => { - return { value: version }; - }); - - return ( - <EuiPage> - <EuiPageBody> - <EuiScreenReaderOnly> - <h1> - <FormattedMessage - id="xpack.monitoring.beats.listing.heading" - defaultMessage="Beats listing" - /> - </h1> - </EuiScreenReaderOnly> - <EuiPageContent> - <Stats stats={stats} /> - <EuiSpacer size="m" /> - {setupModeCallOut} - <EuiMonitoringTable - className="beatsTable" - rows={data} - setupMode={setupMode} - productName={BEATS_SYSTEM_ID} - columns={this.getColumns()} - sorting={sorting} - pagination={pagination} - search={{ - box: { - incremental: true, - placeholder: i18n.translate('xpack.monitoring.beats.filterBeatsPlaceholder', { - defaultMessage: 'Filter Beats...', - }), - }, - filters: [ - { - type: 'field_value_selection', - field: 'type', - name: i18n.translate('xpack.monitoring.beats.instances.typeFilter', { - defaultMessage: 'Type', - }), - options: types, - multiSelect: 'or', - }, - { - type: 'field_value_selection', - field: 'version', - name: i18n.translate('xpack.monitoring.beats.instances.versionFilter', { - defaultMessage: 'Version', - }), - options: versions, - multiSelect: 'or', - }, - ], - }} - onTableChange={onTableChange} - executeQueryOptions={{ - defaultFields: ['name', 'type'], - }} - /> - </EuiPageContent> - </EuiPageBody> - </EuiPage> - ); - } -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/chart/chart_target.test.js b/x-pack/legacy/plugins/monitoring/public/components/chart/chart_target.test.js deleted file mode 100644 index d8a6f1ad6bd9e..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/chart/chart_target.test.js +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import expect from '@kbn/expect'; -import { shallow } from 'enzyme'; -import { ChartTarget } from './chart_target'; - -const props = { - seriesToShow: ['Max Heap', 'Max Heap Used'], - series: [ - { - color: '#3ebeb0', - label: 'Max Heap', - id: 'Max Heap', - data: [ - [1562958960000, 1037959168], - [1562958990000, 1037959168], - [1562959020000, 1037959168], - ], - }, - { - color: '#3b73ac', - label: 'Max Heap Used', - id: 'Max Heap Used', - data: [ - [1562958960000, 639905768], - [1562958990000, 622312416], - [1562959020000, 555967504], - ], - }, - ], - timeRange: { - min: 1562958939851, - max: 1562962539851, - }, - hasLegend: true, - onBrush: () => void 0, - tickFormatter: () => void 0, - updateLegend: () => void 0, -}; - -jest.mock('../../np_imports/ui/chrome', () => { - return { - getBasePath: () => '', - }; -}); - -// TODO: Skipping for now, seems flaky in New Platform (needs more investigation) -describe.skip('Test legends to toggle series: ', () => { - const ids = props.series.map(item => item.id); - - describe('props.series: ', () => { - it('should toggle based on seriesToShow array', () => { - const component = shallow(<ChartTarget {...props} />); - - const componentClass = component.instance(); - - const seriesA = componentClass.filterData(props.series, [ids[0]]); - expect(seriesA.length).to.be(1); - expect(seriesA[0].id).to.be(ids[0]); - - const seriesB = componentClass.filterData(props.series, [ids[1]]); - expect(seriesB.length).to.be(1); - expect(seriesB[0].id).to.be(ids[1]); - - const seriesAB = componentClass.filterData(props.series, ids); - expect(seriesAB.length).to.be(2); - expect(seriesAB[0].id).to.be(ids[0]); - expect(seriesAB[1].id).to.be(ids[1]); - }); - }); -}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/listing/listing.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/listing/listing.js deleted file mode 100644 index 4cf74b3595730..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/listing/listing.js +++ /dev/null @@ -1,455 +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, { Fragment, Component } from 'react'; -import chrome from 'plugins/monitoring/np_imports/ui/chrome'; -import moment from 'moment'; -import numeral from '@elastic/numeral'; -import { capitalize, partial } from 'lodash'; -import { - EuiHealth, - EuiLink, - EuiPage, - EuiPageBody, - EuiPageContent, - EuiToolTip, - EuiCallOut, - EuiSpacer, - EuiIcon, -} from '@elastic/eui'; -import { toastNotifications } from 'ui/notify'; -import { EuiMonitoringTable } from 'plugins/monitoring/components/table'; -import { AlertsIndicator } from 'plugins/monitoring/components/cluster/listing/alerts_indicator'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; -import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants'; - -const IsClusterSupported = ({ isSupported, children }) => { - return isSupported ? children : '-'; -}; - -const STANDALONE_CLUSTER_STORAGE_KEY = 'viewedStandaloneCluster'; - -/* - * This checks if alerts feature is supported via monitoring cluster - * license. If the alerts feature is not supported because the prod cluster - * license is basic, IsClusterSupported makes the status col hidden - * completely - */ -const IsAlertsSupported = props => { - const { alertsMeta = { enabled: true }, clusterMeta = { enabled: true } } = props.cluster.alerts; - if (alertsMeta.enabled && clusterMeta.enabled) { - return <span>{props.children}</span>; - } - - const message = - alertsMeta.message || - clusterMeta.message || - i18n.translate('xpack.monitoring.cluster.listing.unknownHealthMessage', { - defaultMessage: 'Unknown', - }); - - return ( - <EuiToolTip content={message} position="bottom"> - <EuiHealth color="subdued" data-test-subj="alertIcon"> - N/A - </EuiHealth> - </EuiToolTip> - ); -}; - -const getColumns = ( - showLicenseExpiration, - changeCluster, - handleClickIncompatibleLicense, - handleClickInvalidLicense -) => { - return [ - { - name: i18n.translate('xpack.monitoring.cluster.listing.nameColumnTitle', { - defaultMessage: 'Name', - }), - field: 'cluster_name', - sortable: true, - render: (value, cluster) => { - if (cluster.isSupported) { - return ( - <EuiLink - onClick={() => changeCluster(cluster.cluster_uuid, cluster.ccs)} - data-test-subj="clusterLink" - > - {value} - </EuiLink> - ); - } - - // not supported because license is basic/not compatible with multi-cluster - if (cluster.license) { - return ( - <EuiLink - onClick={() => handleClickIncompatibleLicense(cluster.cluster_name)} - data-test-subj="clusterLink" - > - {value} - </EuiLink> - ); - } - - // not supported because license is invalid - return ( - <EuiLink - onClick={() => handleClickInvalidLicense(cluster.cluster_name)} - data-test-subj="clusterLink" - > - {value} - </EuiLink> - ); - }, - }, - { - name: i18n.translate('xpack.monitoring.cluster.listing.statusColumnTitle', { - defaultMessage: 'Status', - }), - field: 'status', - 'data-test-subj': 'alertsStatus', - sortable: true, - render: (_status, cluster) => ( - <IsClusterSupported {...cluster}> - <IsAlertsSupported cluster={cluster}> - <AlertsIndicator alerts={cluster.alerts} /> - </IsAlertsSupported> - </IsClusterSupported> - ), - }, - { - name: i18n.translate('xpack.monitoring.cluster.listing.nodesColumnTitle', { - defaultMessage: 'Nodes', - }), - field: 'elasticsearch.cluster_stats.nodes.count.total', - 'data-test-subj': 'nodesCount', - sortable: true, - render: (total, cluster) => ( - <IsClusterSupported {...cluster}>{numeral(total).format('0,0')}</IsClusterSupported> - ), - }, - { - name: i18n.translate('xpack.monitoring.cluster.listing.indicesColumnTitle', { - defaultMessage: 'Indices', - }), - field: 'elasticsearch.cluster_stats.indices.count', - 'data-test-subj': 'indicesCount', - sortable: true, - render: (count, cluster) => ( - <IsClusterSupported {...cluster}>{numeral(count).format('0,0')}</IsClusterSupported> - ), - }, - { - name: i18n.translate('xpack.monitoring.cluster.listing.dataColumnTitle', { - defaultMessage: 'Data', - }), - field: 'elasticsearch.cluster_stats.indices.store.size_in_bytes', - 'data-test-subj': 'dataSize', - sortable: true, - render: (size, cluster) => ( - <IsClusterSupported {...cluster}>{numeral(size).format('0,0[.]0 b')}</IsClusterSupported> - ), - }, - { - name: i18n.translate('xpack.monitoring.cluster.listing.logstashColumnTitle', { - defaultMessage: 'Logstash', - }), - field: 'logstash.node_count', - 'data-test-subj': 'logstashCount', - sortable: true, - render: (count, cluster) => ( - <IsClusterSupported {...cluster}>{numeral(count).format('0,0')}</IsClusterSupported> - ), - }, - { - name: i18n.translate('xpack.monitoring.cluster.listing.kibanaColumnTitle', { - defaultMessage: 'Kibana', - }), - field: 'kibana.count', - 'data-test-subj': 'kibanaCount', - sortable: true, - render: (count, cluster) => ( - <IsClusterSupported {...cluster}>{numeral(count).format('0,0')}</IsClusterSupported> - ), - }, - { - name: i18n.translate('xpack.monitoring.cluster.listing.licenseColumnTitle', { - defaultMessage: 'License', - }), - field: 'license.type', - 'data-test-subj': 'clusterLicense', - sortable: true, - render: (licenseType, cluster) => { - const license = cluster.license; - - if (!licenseType) { - return ( - <div> - <div className="monTableCell__clusterCellLiscense">N/A</div> - </div> - ); - } - - if (license) { - const licenseExpiry = () => { - if (license.expiry_date_in_millis < moment().valueOf()) { - // license is expired - return <span className="monTableCell__clusterCellExpired">Expired</span>; - } - - // license is fine - return <span>Expires {moment(license.expiry_date_in_millis).format('D MMM YY')}</span>; - }; - - return ( - <div> - <div className="monTableCell__clusterCellLiscense">{capitalize(licenseType)}</div> - <div className="monTableCell__clusterCellExpiration"> - {showLicenseExpiration ? licenseExpiry() : null} - </div> - </div> - ); - } - - // there is no license! - return ( - <EuiLink onClick={() => handleClickInvalidLicense(cluster.cluster_name)}> - <EuiHealth color="subdued" data-test-subj="alertIcon"> - N/A - </EuiHealth> - </EuiLink> - ); - }, - }, - ]; -}; - -const changeCluster = (scope, globalState, kbnUrl, clusterUuid, ccs) => { - scope.$evalAsync(() => { - globalState.cluster_uuid = clusterUuid; - globalState.ccs = ccs; - globalState.save(); - kbnUrl.changePath('/overview'); - }); -}; - -const licenseWarning = (scope, { title, text }) => { - scope.$evalAsync(() => { - toastNotifications.addWarning({ title, text, 'data-test-subj': 'monitoringLicenseWarning' }); - }); -}; - -const handleClickIncompatibleLicense = (scope, clusterName) => { - licenseWarning(scope, { - title: toMountPoint( - <FormattedMessage - id="xpack.monitoring.cluster.listing.incompatibleLicense.warningMessageTitle" - defaultMessage="You can't view the {clusterName} cluster" - values={{ clusterName: '"' + clusterName + '"' }} - /> - ), - text: toMountPoint( - <Fragment> - <p> - <FormattedMessage - id="xpack.monitoring.cluster.listing.incompatibleLicense.noMultiClusterSupportMessage" - defaultMessage="The Basic license does not support multi-cluster monitoring." - /> - </p> - <p> - <FormattedMessage - id="xpack.monitoring.cluster.listing.incompatibleLicense.infoMessage" - defaultMessage="Need to monitor multiple clusters? {getLicenseInfoLink} to enjoy multi-cluster monitoring." - values={{ - getLicenseInfoLink: ( - <EuiLink href="https://www.elastic.co/subscriptions" target="_blank"> - <FormattedMessage - id="xpack.monitoring.cluster.listing.incompatibleLicense.getLicenseLinkLabel" - defaultMessage="Get a license with full functionality" - /> - </EuiLink> - ), - }} - /> - </p> - </Fragment> - ), - }); -}; - -const handleClickInvalidLicense = (scope, clusterName) => { - const licensingPath = `${chrome.getBasePath()}/app/kibana#/management/elasticsearch/license_management/home`; - - licenseWarning(scope, { - title: toMountPoint( - <FormattedMessage - id="xpack.monitoring.cluster.listing.invalidLicense.warningMessageTitle" - defaultMessage="You can't view the {clusterName} cluster" - values={{ clusterName: '"' + clusterName + '"' }} - /> - ), - text: toMountPoint( - <Fragment> - <p> - <FormattedMessage - id="xpack.monitoring.cluster.listing.invalidLicense.invalidInfoMessage" - defaultMessage="The license information is invalid." - /> - </p> - <p> - <FormattedMessage - id="xpack.monitoring.cluster.listing.invalidLicense.infoMessage" - defaultMessage="Need a license? {getBasicLicenseLink} or {getLicenseInfoLink} to enjoy multi-cluster monitoring." - values={{ - getBasicLicenseLink: ( - <EuiLink href={licensingPath}> - <FormattedMessage - id="xpack.monitoring.cluster.listing.invalidLicense.getBasicLicenseLinkLabel" - defaultMessage="Get a free Basic license" - /> - </EuiLink> - ), - getLicenseInfoLink: ( - <EuiLink href="https://www.elastic.co/subscriptions" target="_blank"> - <FormattedMessage - id="xpack.monitoring.cluster.listing.invalidLicense.getLicenseLinkLabel" - defaultMessage="Get a license with full functionality" - /> - </EuiLink> - ), - }} - /> - </p> - </Fragment> - ), - }); -}; - -export class Listing extends Component { - constructor(props) { - super(props); - this.state = { - [STANDALONE_CLUSTER_STORAGE_KEY]: false, - }; - } - - renderStandaloneClusterCallout(changeCluster, storage) { - if (storage.get(STANDALONE_CLUSTER_STORAGE_KEY)) { - return null; - } - - return ( - <div> - <EuiCallOut - color="warning" - title={i18n.translate('xpack.monitoring.cluster.listing.standaloneClusterCallOutTitle', { - defaultMessage: - "It looks like you have instances that aren't connected to an Elasticsearch cluster.", - })} - iconType="link" - > - <p> - <EuiLink - onClick={() => changeCluster(STANDALONE_CLUSTER_CLUSTER_UUID)} - data-test-subj="standaloneClusterLink" - > - <FormattedMessage - id="xpack.monitoring.cluster.listing.standaloneClusterCallOutLink" - defaultMessage="View these instances." - /> - </EuiLink> -   - <FormattedMessage - id="xpack.monitoring.cluster.listing.standaloneClusterCallOutText" - defaultMessage="Or, click Standalone Cluster in the table below" - /> - </p> - <p> - <EuiLink - onClick={() => { - storage.set(STANDALONE_CLUSTER_STORAGE_KEY, true); - this.setState({ [STANDALONE_CLUSTER_STORAGE_KEY]: true }); - }} - > - <EuiIcon type="cross" /> -   - <FormattedMessage - id="xpack.monitoring.cluster.listing.standaloneClusterCallOutDismiss" - defaultMessage="Dismiss" - /> - </EuiLink> - </p> - </EuiCallOut> - <EuiSpacer /> - </div> - ); - } - - render() { - const { angular, clusters, sorting, pagination, onTableChange } = this.props; - - const _changeCluster = partial( - changeCluster, - angular.scope, - angular.globalState, - angular.kbnUrl - ); - const _handleClickIncompatibleLicense = partial(handleClickIncompatibleLicense, angular.scope); - const _handleClickInvalidLicense = partial(handleClickInvalidLicense, angular.scope); - const hasStandaloneCluster = !!clusters.find( - cluster => cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID - ); - - return ( - <EuiPage> - <EuiPageBody> - <EuiPageContent> - {hasStandaloneCluster - ? this.renderStandaloneClusterCallout(_changeCluster, angular.storage) - : null} - <EuiMonitoringTable - className="clusterTable" - rows={clusters} - columns={getColumns( - angular.showLicenseExpiration, - _changeCluster, - _handleClickIncompatibleLicense, - _handleClickInvalidLicense - )} - rowProps={item => { - return { - 'data-test-subj': `clusterRow_${item.cluster_uuid}`, - }; - }} - sorting={{ - ...sorting, - sort: { - ...sorting.sort, - field: 'cluster_name', - }, - }} - pagination={pagination} - search={{ - box: { - incremental: true, - placeholder: angular.scope.filterText, - }, - }} - onTableChange={onTableChange} - executeQueryOptions={{ - defaultFields: ['cluster_name'], - }} - /> - </EuiPageContent> - </EuiPageBody> - </EuiPage> - ); - } -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ml_job_listing/status_icon.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ml_job_listing/status_icon.js deleted file mode 100644 index c2775713171ad..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/ml_job_listing/status_icon.js +++ /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 React from 'react'; -import { StatusIcon } from 'plugins/monitoring/components/status_icon'; -import { i18n } from '@kbn/i18n'; - -export function MachineLearningJobStatusIcon({ status }) { - const type = (() => { - const statusKey = status.toUpperCase(); - - if (statusKey === 'OPENED') { - return StatusIcon.TYPES.GREEN; - } else if (statusKey === 'CLOSED') { - return StatusIcon.TYPES.GRAY; - } else if (statusKey === 'FAILED') { - return StatusIcon.TYPES.RED; - } - - // basically a "changing" state like OPENING or CLOSING - return StatusIcon.TYPES.YELLOW; - })(); - - return ( - <StatusIcon - type={type} - label={i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.statusIconLabel', { - defaultMessage: 'Job Status: {status}', - values: { status }, - })} - /> - ); -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/kibana/status_icon.js b/x-pack/legacy/plugins/monitoring/public/components/kibana/status_icon.js deleted file mode 100644 index 87a2ab4cc4713..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/kibana/status_icon.js +++ /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 React from 'react'; -import { StatusIcon } from 'plugins/monitoring/components/status_icon'; -import { i18n } from '@kbn/i18n'; - -export function KibanaStatusIcon({ status, availability = true }) { - const type = (() => { - if (!availability) { - return StatusIcon.TYPES.GRAY; - } - - const statusKey = status.toUpperCase(); - return StatusIcon.TYPES[statusKey] || StatusIcon.TYPES.YELLOW; - })(); - - return ( - <StatusIcon - type={type} - label={i18n.translate('xpack.monitoring.kibana.statusIconLabel', { - defaultMessage: 'Health: {status}', - values: { - status, - }, - })} - /> - ); -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/license/index.js b/x-pack/legacy/plugins/monitoring/public/components/license/index.js deleted file mode 100644 index d43896d5f8d84..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/license/index.js +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { - EuiPage, - EuiPageBody, - EuiSpacer, - EuiCodeBlock, - EuiPanel, - EuiText, - EuiLink, - EuiFlexGroup, - EuiFlexItem, - EuiScreenReaderOnly, -} from '@elastic/eui'; -import { LicenseStatus, AddLicense } from 'plugins/xpack_main/components'; -import { FormattedMessage } from '@kbn/i18n/react'; -import chrome from 'plugins/monitoring/np_imports/ui/chrome'; - -const licenseManagement = `${chrome.getBasePath()}/app/kibana#/management/elasticsearch/license_management`; - -const LicenseUpdateInfoForPrimary = ({ isPrimaryCluster, uploadLicensePath }) => { - if (!isPrimaryCluster) { - return null; - } - - // viewed license is for the cluster directly connected to Kibana - return <AddLicense uploadPath={uploadLicensePath} />; -}; - -const LicenseUpdateInfoForRemote = ({ isPrimaryCluster }) => { - if (isPrimaryCluster) { - return null; - } - - // viewed license is for a remote monitored cluster not directly connected to Kibana - return ( - <EuiPanel> - <p> - <FormattedMessage - id="xpack.monitoring.license.howToUpdateLicenseDescription" - defaultMessage="To update the license for this cluster, provide the license file through - the Elasticsearch {apiText}:" - values={{ - apiText: 'API', - }} - /> - </p> - <EuiSpacer /> - <EuiCodeBlock> - {`curl -XPUT -u <user> 'https://<host>:<port>/_license' -H 'Content-Type: application/json' -d @license.json`} - </EuiCodeBlock> - </EuiPanel> - ); -}; - -export function License(props) { - const { status, type, isExpired, expiryDate } = props; - return ( - <EuiPage> - <EuiScreenReaderOnly> - <h1> - <FormattedMessage id="xpack.monitoring.license.heading" defaultMessage="License" /> - </h1> - </EuiScreenReaderOnly> - <EuiPageBody> - <LicenseStatus isExpired={isExpired} status={status} type={type} expiryDate={expiryDate} /> - <EuiSpacer /> - - <EuiFlexGroup justifyContent="center"> - <EuiFlexItem grow={false}> - <LicenseUpdateInfoForPrimary {...props} /> - <LicenseUpdateInfoForRemote {...props} /> - </EuiFlexItem> - </EuiFlexGroup> - - <EuiSpacer /> - <EuiText size="s" textAlign="center"> - <p> - For more license options please visit  - <EuiLink href={licenseManagement}>License Management</EuiLink>. - </p> - </EuiText> - </EuiPageBody> - </EuiPage> - ); -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js deleted file mode 100644 index 9fa33802b0202..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import { EuiSpacer, EuiCodeBlock, EuiLink, EuiText } from '@elastic/eui'; -import { Monospace } from '../components/monospace'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; -import { getMigrationStatusStep, getSecurityStep } from '../common_instructions'; - -export function getApmInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl }) { - const securitySetup = getSecurityStep( - `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-configuration.html` - ); - - const installMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.apmInstructions.installMetricbeatTitle', - { - defaultMessage: 'Install Metricbeat on the same server as the APM server', - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-installation.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.apmInstructions.installMetricbeatLinkText" - defaultMessage="Follow the instructions here." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const enableMetricbeatModuleStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.apmInstructions.enableMetricbeatModuleTitle', - { - defaultMessage: 'Enable and configure the Beat x-pack module in Metricbeat', - } - ), - children: ( - <Fragment> - <EuiCodeBlock isCopyable language="bash"> - metricbeat modules enable beat-xpack - </EuiCodeBlock> - <EuiSpacer size="s" /> - <EuiText> - <p> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.apmInstructions.enableMetricbeatModuleDescription" - defaultMessage="By default the module will collect APM server monitoring metrics from http://localhost:5066. If the local APM server has a different address, you must specify it via the {hosts} setting in the {file} file." - values={{ - hosts: <Monospace>hosts</Monospace>, - file: <Monospace>modules.d/beat-xpack.yml</Monospace>, - }} - /> - </p> - </EuiText> - {securitySetup} - </Fragment> - ), - }; - - const configureMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.apmInstructions.configureMetricbeatTitle', - { - defaultMessage: 'Configure Metricbeat to send to the monitoring cluster', - } - ), - children: ( - <Fragment> - <EuiText> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.apmInstructions.configureMetricbeatDescription" - defaultMessage="Make these changes in your {file}." - values={{ - file: <Monospace>metricbeat.yml</Monospace>, - }} - /> - </EuiText> - <EuiSpacer size="s" /> - <EuiCodeBlock isCopyable> - {`output.elasticsearch: - hosts: [${esMonitoringUrl}] ## Monitoring cluster - - # Optional protocol and basic auth credentials. - #protocol: "https" - #username: "elastic" - #password: "changeme" -`} - </EuiCodeBlock> - {securitySetup} - </Fragment> - ), - }; - - const startMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.apmInstructions.startMetricbeatTitle', - { - defaultMessage: 'Start Metricbeat', - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-starting.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.apmInstructions.startMetricbeatLinkText" - defaultMessage="Follow the instructions here." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const migrationStatusStep = getMigrationStatusStep(product); - - return [ - installMetricbeatStep, - enableMetricbeatModuleStep, - configureMetricbeatStep, - startMetricbeatStep, - migrationStatusStep, - ]; -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js deleted file mode 100644 index 56e7c9b5af064..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import { EuiSpacer, EuiCodeBlock, EuiLink, EuiCallOut, EuiText } from '@elastic/eui'; -import { Monospace } from '../components/monospace'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { UNDETECTED_BEAT_TYPE, DEFAULT_BEAT_FOR_URLS } from './common_beats_instructions'; -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; -import { getMigrationStatusStep, getSecurityStep } from '../common_instructions'; - -export function getBeatsInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl }) { - const beatType = product.beatType; - const securitySetup = getSecurityStep( - `${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-configuration.html` - ); - - const installMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.beatsInstructions.installMetricbeatTitle', - { - defaultMessage: 'Install Metricbeat on the same server as this {beatType}', - values: { - beatType: beatType || UNDETECTED_BEAT_TYPE, - }, - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-installation.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.beatsInstructions.installMetricbeatLinkText" - defaultMessage="Follow the instructions here." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const httpEndpointUrl = - `${ELASTIC_WEBSITE_URL}guide/en/beats/${beatType || DEFAULT_BEAT_FOR_URLS}` + - `/${DOC_LINK_VERSION}/http-endpoint.html`; - - const enableMetricbeatModuleStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleTitle', - { - defaultMessage: 'Enable and configure the Beat x-pack module in Metricbeat', - } - ), - children: ( - <Fragment> - <EuiCodeBlock isCopyable language="bash"> - metricbeat modules enable beat-xpack - </EuiCodeBlock> - <EuiSpacer size="s" /> - <EuiText> - <p> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleDescription" - defaultMessage="By default the module will collect {beatType} monitoring metrics from http://localhost:5066. If the {beatType} instance being monitored has a different address, you must specify it via the {hosts} setting in the {file} file." - values={{ - hosts: <Monospace>hosts</Monospace>, - file: <Monospace>modules.d/beat-xpack.yml</Monospace>, - beatType: beatType || UNDETECTED_BEAT_TYPE, - }} - /> - </p> - </EuiText> - <EuiSpacer size="m" /> - <EuiCallOut - color="warning" - iconType="help" - title={ - <EuiText> - <p> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleHttpEnabledDirections" - defaultMessage="In order for Metricbeat to collect metrics from the running {beatType}, you need to {link}." - values={{ - link: ( - <EuiLink href={httpEndpointUrl} target="_blank"> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleHttpEnabledDirectionsLinkText" - defaultMessage="enable an HTTP endpoint for the {beatType} instance being monitored" - values={{ - beatType, - }} - /> - </EuiLink> - ), - beatType: beatType || UNDETECTED_BEAT_TYPE, - }} - /> - </p> - </EuiText> - } - /> - {securitySetup} - </Fragment> - ), - }; - - const configureMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.beatsInstructions.configureMetricbeatTitle', - { - defaultMessage: 'Configure Metricbeat to send to the monitoring cluster', - } - ), - children: ( - <Fragment> - <EuiText> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.beatsInstructions.configureMetricbeatDescription" - defaultMessage="Make these changes in your {file}." - values={{ - file: <Monospace>metricbeat.yml</Monospace>, - }} - /> - </EuiText> - <EuiSpacer size="s" /> - <EuiCodeBlock isCopyable> - {`output.elasticsearch: - hosts: [${esMonitoringUrl}] ## Monitoring cluster - - # Optional protocol and basic auth credentials. - #protocol: "https" - #username: "elastic" - #password: "changeme" -`} - </EuiCodeBlock> - {securitySetup} - </Fragment> - ), - }; - - const startMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.beatsInstructions.startMetricbeatTitle', - { - defaultMessage: 'Start Metricbeat', - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-starting.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.beatsInstructions.startMetricbeatLinkText" - defaultMessage="Follow the instructions here." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const migrationStatusStep = getMigrationStatusStep(product); - - return [ - installMetricbeatStep, - enableMetricbeatModuleStep, - configureMetricbeatStep, - startMetricbeatStep, - migrationStatusStep, - ]; -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/enable_metricbeat_instructions.js deleted file mode 100644 index 36b3dd21ff43e..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/elasticsearch/enable_metricbeat_instructions.js +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import { EuiSpacer, EuiCodeBlock, EuiLink, EuiText } from '@elastic/eui'; -import { Monospace } from '../components/monospace'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; -import { getSecurityStep, getMigrationStatusStep } from '../common_instructions'; - -export function getElasticsearchInstructionsForEnablingMetricbeat( - product, - _meta, - { esMonitoringUrl } -) { - const securitySetup = getSecurityStep( - `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/configuring-metricbeat.html` - ); - - const installMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.elasticsearchInstructions.installMetricbeatTitle', - { - defaultMessage: 'Install Metricbeat on the same server as Elasticsearch', - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-installation.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.elasticsearchInstructions.installMetricbeatLinkText" - defaultMessage="Follow these instructions." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const enableMetricbeatModuleStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.elasticsearchInstructions.enableMetricbeatModuleTitle', - { - defaultMessage: 'Enable and configure the Elasticsearch x-pack module in Metricbeat', - } - ), - children: ( - <Fragment> - <EuiText> - <p> - {i18n.translate( - 'xpack.monitoring.metricbeatMigration.elasticsearchInstructions.enableMetricbeatModuleInstallDirectory', - { - defaultMessage: 'From the installation directory, run:', - } - )} - </p> - </EuiText> - <EuiSpacer size="s" /> - <EuiCodeBlock isCopyable language="bash"> - metricbeat modules enable elasticsearch-xpack - </EuiCodeBlock> - <EuiSpacer size="s" /> - <EuiText> - <p> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.elasticsearchInstructions.enableMetricbeatModuleDescription" - defaultMessage="By default the module collects Elasticsearch metrics from {url}. - If the local server has a different address, add it to the hosts setting in {module}." - values={{ - module: <Monospace>modules.d/elasticsearch-xpack.yml</Monospace>, - url: <Monospace>http://localhost:9200</Monospace>, - }} - /> - </p> - </EuiText> - {securitySetup} - </Fragment> - ), - }; - - const configureMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.elasticsearchInstructions.configureMetricbeatTitle', - { - defaultMessage: 'Configure Metricbeat to send data to the monitoring cluster', - } - ), - children: ( - <Fragment> - <EuiText> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.elasticsearchInstructions.configureMetricbeatDescription" - defaultMessage="Modify {file} to set the connection information." - values={{ - file: <Monospace>metricbeat.yml</Monospace>, - }} - /> - </EuiText> - <EuiSpacer size="s" /> - <EuiCodeBlock isCopyable> - {`output.elasticsearch: - hosts: [${esMonitoringUrl}] ## Monitoring cluster - - # Optional protocol and basic auth credentials. - #protocol: "https" - #username: "elastic" - #password: "changeme" -`} - </EuiCodeBlock> - {securitySetup} - </Fragment> - ), - }; - - const startMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.elasticsearchInstructions.startMetricbeatTitle', - { - defaultMessage: 'Start Metricbeat', - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-starting.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.elasticsearchInstructions.startMetricbeatLinkText" - defaultMessage="Follow these instructions." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const migrationStatusStep = getMigrationStatusStep(product); - - return [ - installMetricbeatStep, - enableMetricbeatModuleStep, - configureMetricbeatStep, - startMetricbeatStep, - migrationStatusStep, - ]; -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/enable_metricbeat_instructions.js deleted file mode 100644 index 98c75dbfe4b37..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/kibana/enable_metricbeat_instructions.js +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import { EuiSpacer, EuiCodeBlock, EuiLink, EuiText } from '@elastic/eui'; -import { Monospace } from '../components/monospace'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; -import { getMigrationStatusStep, getSecurityStep } from '../common_instructions'; - -export function getKibanaInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl }) { - const securitySetup = getSecurityStep( - `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/monitoring-metricbeat.html` - ); - - const installMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.kibanaInstructions.installMetricbeatTitle', - { - defaultMessage: 'Install Metricbeat on the same server as Kibana', - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-installation.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.kibanaInstructions.installMetricbeatLinkText" - defaultMessage="Follow the instructions here." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const enableMetricbeatModuleStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.kibanaInstructions.enableMetricbeatModuleTitle', - { - defaultMessage: 'Enable and configure the Kibana x-pack module in Metricbeat', - } - ), - children: ( - <Fragment> - <EuiCodeBlock isCopyable language="bash"> - metricbeat modules enable kibana-xpack - </EuiCodeBlock> - <EuiSpacer size="s" /> - <EuiText> - <p> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.kibanaInstructions.enableMetricbeatModuleDescription" - defaultMessage="By default the module will collect Kibana monitoring metrics from http://localhost:5601. If the local Kibana instance has a different address, you must specify it via the {hosts} setting in the {file} file." - values={{ - hosts: <Monospace>hosts</Monospace>, - file: <Monospace>modules.d/kibana-xpack.yml</Monospace>, - }} - /> - </p> - </EuiText> - {securitySetup} - </Fragment> - ), - }; - - const configureMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.kibanaInstructions.configureMetricbeatTitle', - { - defaultMessage: 'Configure Metricbeat to send to the monitoring cluster', - } - ), - children: ( - <Fragment> - <EuiText> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.kibanaInstructions.configureMetricbeatDescription" - defaultMessage="Make these changes in your {file}." - values={{ - file: <Monospace>metricbeat.yml</Monospace>, - }} - /> - </EuiText> - <EuiSpacer size="s" /> - <EuiCodeBlock isCopyable> - {`output.elasticsearch: - hosts: [${esMonitoringUrl}] ## Monitoring cluster - - # Optional protocol and basic auth credentials. - #protocol: "https" - #username: "elastic" - #password: "changeme" -`} - </EuiCodeBlock> - {securitySetup} - </Fragment> - ), - }; - - const startMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.kibanaInstructions.startMetricbeatTitle', - { - defaultMessage: 'Start Metricbeat', - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-starting.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.kibanaInstructions.startMetricbeatLinkText" - defaultMessage="Follow the instructions here." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const migrationStatusStep = getMigrationStatusStep(product); - - return [ - installMetricbeatStep, - enableMetricbeatModuleStep, - configureMetricbeatStep, - startMetricbeatStep, - migrationStatusStep, - ]; -} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js deleted file mode 100644 index 4a36f394e4bd5..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import React, { Fragment } from 'react'; -import { EuiSpacer, EuiCodeBlock, EuiLink, EuiText } from '@elastic/eui'; -import { Monospace } from '../components/monospace'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; -import { getMigrationStatusStep, getSecurityStep } from '../common_instructions'; - -export function getLogstashInstructionsForEnablingMetricbeat(product, _meta, { esMonitoringUrl }) { - const securitySetup = getSecurityStep( - `${ELASTIC_WEBSITE_URL}guide/en/logstash/${DOC_LINK_VERSION}/monitoring-with-metricbeat.html` - ); - - const installMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.logstashInstructions.installMetricbeatTitle', - { - defaultMessage: 'Install Metricbeat on the same server as Logstash', - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-installation.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.logstashInstructions.installMetricbeatLinkText" - defaultMessage="Follow the instructions here." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const enableMetricbeatModuleStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.logstashInstructions.enableMetricbeatModuleTitle', - { - defaultMessage: 'Enable and configure the Logstash x-pack module in Metricbeat', - } - ), - children: ( - <Fragment> - <EuiCodeBlock isCopyable language="bash"> - metricbeat modules enable logstash-xpack - </EuiCodeBlock> - <EuiSpacer size="s" /> - <EuiText> - <p> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.logstashInstructions.enableMetricbeatModuleDescription" - defaultMessage="By default the module will collect Logstash monitoring metrics from http://localhost:9600. If the local Logstash instance has a different address, you must specify it via the {hosts} setting in the {file} file." - values={{ - hosts: <Monospace>hosts</Monospace>, - file: <Monospace>modules.d/logstash-xpack.yml</Monospace>, - }} - /> - </p> - </EuiText> - {securitySetup} - </Fragment> - ), - }; - - const configureMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.logstashInstructions.configureMetricbeatTitle', - { - defaultMessage: 'Configure Metricbeat to send to the monitoring cluster', - } - ), - children: ( - <Fragment> - <EuiText> - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.logstashInstructions.configureMetricbeatDescription" - defaultMessage="Make these changes in your {file}." - values={{ - file: <Monospace>metricbeat.yml</Monospace>, - }} - /> - </EuiText> - <EuiSpacer size="s" /> - <EuiCodeBlock isCopyable> - {`output.elasticsearch: - hosts: [${esMonitoringUrl}] ## Monitoring cluster - - # Optional protocol and basic auth credentials. - #protocol: "https" - #username: "elastic" - #password: "changeme" -`} - </EuiCodeBlock> - {securitySetup} - </Fragment> - ), - }; - - const startMetricbeatStep = { - title: i18n.translate( - 'xpack.monitoring.metricbeatMigration.logstashInstructions.startMetricbeatTitle', - { - defaultMessage: 'Start Metricbeat', - } - ), - children: ( - <EuiText> - <p> - <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/beats/metricbeat/${DOC_LINK_VERSION}/metricbeat-starting.html`} - target="_blank" - > - <FormattedMessage - id="xpack.monitoring.metricbeatMigration.logstashInstructions.startMetricbeatLinkText" - defaultMessage="Follow the instructions here." - /> - </EuiLink> - </p> - </EuiText> - ), - }; - - const migrationStatusStep = getMigrationStatusStep(product); - - return [ - installMetricbeatStep, - enableMetricbeatModuleStep, - configureMetricbeatStep, - startMetricbeatStep, - migrationStatusStep, - ]; -} diff --git a/x-pack/legacy/plugins/monitoring/public/directives/all.js b/x-pack/legacy/plugins/monitoring/public/directives/all.js deleted file mode 100644 index 43ad80a7a7e94..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/directives/all.js +++ /dev/null @@ -1,10 +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 './main'; -import './elasticsearch/ml_job_listing'; -import './beats/overview'; -import './beats/beat'; diff --git a/x-pack/legacy/plugins/monitoring/public/directives/beats/beat/index.js b/x-pack/legacy/plugins/monitoring/public/directives/beats/beat/index.js deleted file mode 100644 index c86315fc03482..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/directives/beats/beat/index.js +++ /dev/null @@ -1,40 +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 { render, unmountComponentAtNode } from 'react-dom'; -import { uiModules } from 'plugins/monitoring/np_imports/ui/modules'; -import { Beat } from 'plugins/monitoring/components/beats/beat'; -import { I18nContext } from 'ui/i18n'; - -const uiModule = uiModules.get('monitoring/directives', []); -uiModule.directive('monitoringBeatsBeat', () => { - return { - restrict: 'E', - scope: { - data: '=', - onBrush: '<', - zoomInfo: '<', - }, - link(scope, $el) { - scope.$on('$destroy', () => $el && $el[0] && unmountComponentAtNode($el[0])); - - scope.$watch('data', (data = {}) => { - render( - <I18nContext> - <Beat - summary={data.summary} - metrics={data.metrics} - onBrush={scope.onBrush} - zoomInfo={scope.zoomInfo} - /> - </I18nContext>, - $el[0] - ); - }); - }, - }; -}); diff --git a/x-pack/legacy/plugins/monitoring/public/directives/beats/overview/index.js b/x-pack/legacy/plugins/monitoring/public/directives/beats/overview/index.js deleted file mode 100644 index fb78b6a2e0300..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/directives/beats/overview/index.js +++ /dev/null @@ -1,35 +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 { render, unmountComponentAtNode } from 'react-dom'; -import { uiModules } from 'plugins/monitoring/np_imports/ui/modules'; -import { BeatsOverview } from 'plugins/monitoring/components/beats/overview'; -import { I18nContext } from 'ui/i18n'; - -const uiModule = uiModules.get('monitoring/directives', []); -uiModule.directive('monitoringBeatsOverview', () => { - return { - restrict: 'E', - scope: { - data: '=', - onBrush: '<', - zoomInfo: '<', - }, - link(scope, $el) { - scope.$on('$destroy', () => $el && $el[0] && unmountComponentAtNode($el[0])); - - scope.$watch('data', (data = {}) => { - render( - <I18nContext> - <BeatsOverview {...data} onBrush={scope.onBrush} zoomInfo={scope.zoomInfo} /> - </I18nContext>, - $el[0] - ); - }); - }, - }; -}); diff --git a/x-pack/legacy/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js b/x-pack/legacy/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js deleted file mode 100644 index 8f35bd599ac49..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js +++ /dev/null @@ -1,167 +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 { capitalize } from 'lodash'; -import numeral from '@elastic/numeral'; -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { I18nContext } from 'ui/i18n'; -import { uiModules } from 'plugins/monitoring/np_imports/ui/modules'; -import { EuiMonitoringTable } from 'plugins/monitoring/components/table'; -import { MachineLearningJobStatusIcon } from 'plugins/monitoring/components/elasticsearch/ml_job_listing/status_icon'; -import { LARGE_ABBREVIATED, LARGE_BYTES } from '../../../../common/formatting'; -import { EuiLink, EuiPage, EuiPageContent, EuiPageBody, EuiPanel, EuiSpacer } from '@elastic/eui'; -import { ClusterStatus } from '../../../components/elasticsearch/cluster_status'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -const getColumns = (kbnUrl, scope) => [ - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.jobIdTitle', { - defaultMessage: 'Job ID', - }), - field: 'job_id', - sortable: true, - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.stateTitle', { - defaultMessage: 'State', - }), - field: 'state', - sortable: true, - render: state => ( - <div> - <MachineLearningJobStatusIcon status={state} /> -   - {capitalize(state)} - </div> - ), - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.processedRecordsTitle', { - defaultMessage: 'Processed Records', - }), - field: 'data_counts.processed_record_count', - sortable: true, - render: value => <span>{numeral(value).format(LARGE_ABBREVIATED)}</span>, - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.modelSizeTitle', { - defaultMessage: 'Model Size', - }), - field: 'model_size_stats.model_bytes', - sortable: true, - render: value => <span>{numeral(value).format(LARGE_BYTES)}</span>, - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.forecastsTitle', { - defaultMessage: 'Forecasts', - }), - field: 'forecasts_stats.total', - sortable: true, - render: value => <span>{numeral(value).format(LARGE_ABBREVIATED)}</span>, - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.nodeTitle', { - defaultMessage: 'Node', - }), - field: 'node.name', - sortable: true, - render: (name, node) => { - if (node) { - return ( - <EuiLink - onClick={() => { - scope.$evalAsync(() => kbnUrl.changePath(`/elasticsearch/nodes/${node.id}`)); - }} - > - {name} - </EuiLink> - ); - } - - return ( - <FormattedMessage - id="xpack.monitoring.elasticsearch.mlJobListing.noDataLabel" - defaultMessage="N/A" - /> - ); - }, - }, -]; - -const uiModule = uiModules.get('monitoring/directives', []); -uiModule.directive('monitoringMlListing', kbnUrl => { - return { - restrict: 'E', - scope: { - jobs: '=', - paginationSettings: '=', - sorting: '=', - onTableChange: '=', - status: '=', - }, - link(scope, $el) { - scope.$on('$destroy', () => $el && $el[0] && unmountComponentAtNode($el[0])); - const columns = getColumns(kbnUrl, scope); - - const filterJobsPlaceholder = i18n.translate( - 'xpack.monitoring.elasticsearch.mlJobListing.filterJobsPlaceholder', - { - defaultMessage: 'Filter Jobs…', - } - ); - - scope.$watch('jobs', (jobs = []) => { - const mlTable = ( - <I18nContext> - <EuiPage> - <EuiPageBody> - <EuiPanel> - <ClusterStatus stats={scope.status} /> - </EuiPanel> - <EuiSpacer size="m" /> - <EuiPageContent> - <EuiMonitoringTable - className="mlJobsTable" - rows={jobs} - columns={columns} - sorting={{ - ...scope.sorting, - sort: { - ...scope.sorting.sort, - field: 'job_id', - }, - }} - pagination={scope.paginationSettings} - message={i18n.translate( - 'xpack.monitoring.elasticsearch.mlJobListing.noJobsDescription', - { - defaultMessage: - 'There are no Machine Learning Jobs that match your query. Try changing the time range selection.', - } - )} - search={{ - box: { - incremental: true, - placeholder: filterJobsPlaceholder, - }, - }} - onTableChange={scope.onTableChange} - executeQueryOptions={{ - defaultFields: ['job_id'], - }} - /> - </EuiPageContent> - </EuiPageBody> - </EuiPage> - </I18nContext> - ); - render(mlTable, $el[0]); - }); - }, - }; -}); diff --git a/x-pack/legacy/plugins/monitoring/public/directives/main/index.js b/x-pack/legacy/plugins/monitoring/public/directives/main/index.js deleted file mode 100644 index 4e09225cb85e8..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/directives/main/index.js +++ /dev/null @@ -1,263 +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 { render, unmountComponentAtNode } from 'react-dom'; -import { EuiSelect, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; -import { uiModules } from 'plugins/monitoring/np_imports/ui/modules'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { shortenPipelineHash } from '../../../common/formatting'; -import { getSetupModeState, initSetupModeState } from '../../lib/setup_mode'; -import { Subscription } from 'rxjs'; - -const setOptions = controller => { - if ( - !controller.pipelineVersions || - !controller.pipelineVersions.length || - !controller.pipelineDropdownElement - ) { - return; - } - - render( - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiTitle - style={{ maxWidth: 400, lineHeight: '40px', overflow: 'hidden', whiteSpace: 'nowrap' }} - > - <h2>{controller.pipelineId}</h2> - </EuiTitle> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiSelect - value={controller.pipelineHash} - options={controller.pipelineVersions.map(option => { - return { - text: i18n.translate( - 'xpack.monitoring.logstashNavigation.pipelineVersionDescription', - { - defaultMessage: - 'Version active {relativeLastSeen} and first seen {relativeFirstSeen}', - values: { - relativeLastSeen: option.relativeLastSeen, - relativeFirstSeen: option.relativeFirstSeen, - }, - } - ), - value: option.hash, - }; - })} - onChange={controller.onChangePipelineHash} - /> - </EuiFlexItem> - </EuiFlexGroup>, - controller.pipelineDropdownElement - ); -}; - -/* - * Manage data and provide helper methods for the "main" directive's template - */ -export class MonitoringMainController { - // called internally by Angular - constructor() { - this.inListing = false; - this.inAlerts = false; - this.inOverview = false; - this.inElasticsearch = false; - this.inKibana = false; - this.inLogstash = false; - this.inBeats = false; - this.inApm = false; - } - - addTimerangeObservers = () => { - this.subscriptions = new Subscription(); - - const refreshIntervalUpdated = () => { - const { value: refreshInterval, pause: isPaused } = timefilter.getRefreshInterval(); - this.datePicker.onRefreshChange({ refreshInterval, isPaused }, true); - }; - - const timeUpdated = () => { - this.datePicker.onTimeUpdate({ dateRange: timefilter.getTime() }, true); - }; - - this.subscriptions.add( - timefilter.getRefreshIntervalUpdate$().subscribe(refreshIntervalUpdated) - ); - this.subscriptions.add(timefilter.getTimeUpdate$().subscribe(timeUpdated)); - }; - - dropdownLoadedHandler() { - this.pipelineDropdownElement = document.querySelector('#dropdown-elm'); - setOptions(this); - } - - // kick things off from the directive link function - setup(options) { - this._licenseService = options.licenseService; - this._breadcrumbsService = options.breadcrumbsService; - this._kbnUrlService = options.kbnUrlService; - this._executorService = options.executorService; - - Object.assign(this, options.attributes); - - this.navName = `${this.name}-nav`; - - // set the section we're navigated in - if (this.product) { - this.inElasticsearch = this.product === 'elasticsearch'; - this.inKibana = this.product === 'kibana'; - this.inLogstash = this.product === 'logstash'; - this.inBeats = this.product === 'beats'; - this.inApm = this.product === 'apm'; - } else { - this.inOverview = this.name === 'overview'; - this.inAlerts = this.name === 'alerts'; - this.inListing = this.name === 'listing'; // || this.name === 'no-data'; - } - - if (!this.inListing) { - // no breadcrumbs in cluster listing page - this.breadcrumbs = this._breadcrumbsService(options.clusterName, this); - } - - if (this.pipelineHash) { - this.pipelineHashShort = shortenPipelineHash(this.pipelineHash); - this.onChangePipelineHash = () => { - return this._kbnUrlService.changePath( - `/logstash/pipelines/${this.pipelineId}/${this.pipelineHash}` - ); - }; - } - - this.datePicker = { - enableTimeFilter: timefilter.isTimeRangeSelectorEnabled(), - timeRange: timefilter.getTime(), - refreshInterval: timefilter.getRefreshInterval(), - onRefreshChange: ({ isPaused, refreshInterval }, skipSet = false) => { - this.datePicker.refreshInterval = { - pause: isPaused, - value: refreshInterval, - }; - if (!skipSet) { - timefilter.setRefreshInterval({ - pause: isPaused, - value: refreshInterval ? refreshInterval : this.datePicker.refreshInterval.value, - }); - } - }, - onTimeUpdate: ({ dateRange }, skipSet = false) => { - this.datePicker.timeRange = { - ...dateRange, - }; - if (!skipSet) { - timefilter.setTime(dateRange); - } - this._executorService.cancel(); - this._executorService.run(); - }, - }; - } - - // check whether to "highlight" a tab - isActiveTab(testPath) { - return this.name === testPath; - } - - // check whether to show ML tab - isMlSupported() { - return this._licenseService.mlIsSupported(); - } - - isDisabledTab(product) { - const setupMode = getSetupModeState(); - if (!setupMode.enabled || !setupMode.data) { - return false; - } - - const data = setupMode.data[product] || {}; - if (data.totalUniqueInstanceCount === 0) { - return true; - } - if ( - data.totalUniqueInternallyCollectedCount === 0 && - data.totalUniqueFullyMigratedCount === 0 && - data.totalUniquePartiallyMigratedCount === 0 - ) { - return true; - } - return false; - } -} - -const uiModule = uiModules.get('monitoring/directives', []); -uiModule.directive('monitoringMain', (breadcrumbs, license, kbnUrl, $injector) => { - const $executor = $injector.get('$executor'); - - return { - restrict: 'E', - transclude: true, - template, - controller: MonitoringMainController, - controllerAs: 'monitoringMain', - bindToController: true, - link(scope, _element, attributes, controller) { - controller.addTimerangeObservers(); - initSetupModeState(scope, $injector, () => { - controller.setup(getSetupObj()); - }); - if (!scope.cluster) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - scope.cluster = ($route.current.locals.clusters || []).find( - cluster => cluster.cluster_uuid === globalState.cluster_uuid - ); - } - - function getSetupObj() { - return { - licenseService: license, - breadcrumbsService: breadcrumbs, - executorService: $executor, - kbnUrlService: kbnUrl, - attributes: { - name: attributes.name, - product: attributes.product, - instance: attributes.instance, - resolver: attributes.resolver, - page: attributes.page, - tabIconClass: attributes.tabIconClass, - tabIconLabel: attributes.tabIconLabel, - pipelineId: attributes.pipelineId, - pipelineHash: attributes.pipelineHash, - pipelineVersions: get(scope, 'pageData.versions'), - isCcrEnabled: attributes.isCcrEnabled === 'true' || attributes.isCcrEnabled === true, - }, - clusterName: get(scope, 'cluster.cluster_name'), - }; - } - - const setupObj = getSetupObj(); - controller.setup(setupObj); - Object.keys(setupObj.attributes).forEach(key => { - attributes.$observe(key, () => controller.setup(getSetupObj())); - }); - scope.$on('$destroy', () => { - controller.pipelineDropdownElement && - unmountComponentAtNode(controller.pipelineDropdownElement); - controller.subscriptions && controller.subscriptions.unsubscribe(); - }); - scope.$watch('pageData.versions', versions => { - controller.pipelineVersions = versions; - setOptions(controller); - }); - }, - }; -}); diff --git a/x-pack/legacy/plugins/monitoring/public/filters/index.js b/x-pack/legacy/plugins/monitoring/public/filters/index.js deleted file mode 100644 index a67770ff50dc8..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/filters/index.js +++ /dev/null @@ -1,30 +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 { capitalize } from 'lodash'; -import { uiModules } from 'plugins/monitoring/np_imports/ui/modules'; -import { formatNumber, formatMetric } from 'plugins/monitoring/lib/format_number'; -import { extractIp } from 'plugins/monitoring/lib/extract_ip'; - -const uiModule = uiModules.get('monitoring/filters', []); - -uiModule.filter('capitalize', function() { - return function(input) { - return capitalize(input.toLowerCase()); - }; -}); - -uiModule.filter('formatNumber', function() { - return formatNumber; -}); - -uiModule.filter('formatMetric', function() { - return formatMetric; -}); - -uiModule.filter('extractIp', function() { - return extractIp; -}); diff --git a/x-pack/legacy/plugins/monitoring/public/hacks/__tests__/toggle_app_link_in_nav.js b/x-pack/legacy/plugins/monitoring/public/hacks/__tests__/toggle_app_link_in_nav.js deleted file mode 100644 index 6ed3371486740..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/hacks/__tests__/toggle_app_link_in_nav.js +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { uiModules } from 'ui/modules'; - -uiModules.get('kibana').constant('monitoringUiEnabled', true); diff --git a/x-pack/legacy/plugins/monitoring/public/hacks/toggle_app_link_in_nav.js b/x-pack/legacy/plugins/monitoring/public/hacks/toggle_app_link_in_nav.js deleted file mode 100644 index 9448e826f3723..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/hacks/toggle_app_link_in_nav.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { uiModules } from 'ui/modules'; -import { npStart } from 'ui/new_platform'; - -uiModules.get('monitoring/hacks').run(monitoringUiEnabled => { - if (monitoringUiEnabled) { - return; - } - - npStart.core.chrome.navLinks.update('monitoring', { hidden: true }); -}); diff --git a/x-pack/legacy/plugins/monitoring/public/icons/monitoring.svg b/x-pack/legacy/plugins/monitoring/public/icons/monitoring.svg deleted file mode 100644 index e00faca26a251..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/icons/monitoring.svg +++ /dev/null @@ -1,16 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Generator: Adobe Illustrator 21.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> -<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" - width="20px" height="20px" viewBox="0 0 20 20" style="enable-background:new 0 0 20 20;" xml:space="preserve"> -<style type="text/css"> - .st0{fill:#FFFFFF;} -</style> -<g> - <path class="st0" d="M1.9,12.4h2.8l1.6-3c0.1-0.2,0.3-0.3,0.5-0.3c0.2,0,0.4,0.1,0.4,0.3l1.5,4.2l2.5-7.8c0.1-0.2,0.2-0.3,0.5-0.3 - c0.3,0,0.4,0.1,0.5,0.3l2.6,6.5h3.2l0.2-0.2c2.3-2.3,2.2-6-0.1-8.3C16,1.6,12.3,1.5,10,3.7c-2.3-2.3-6-2.3-8.3,0 - c-2.3,2.3-2.3,6.1,0,8.5L1.9,12.4z"/> - <path class="st0" d="M14.5,13.4c-0.2,0-0.4-0.1-0.5-0.3l-2.2-5.6l-2.6,7.9c-0.1,0.2-0.3,0.3-0.5,0.3c0,0,0,0,0,0 - c-0.2,0-0.4-0.1-0.5-0.3l-1.6-4.5l-1.2,2.3c-0.1,0.2-0.3,0.3-0.4,0.3H2.9l5.8,5.9c0,0,0,0,0,0C9,19.7,9.4,19.9,9.7,20c0,0,0,0,0,0 - c0.1,0,0.1,0,0.2,0c0.1,0,0.1,0,0.2,0c0,0,0,0,0,0c0.3,0,0.6-0.2,0.9-0.4l6-6.1H14.5z"/> -</g> -</svg> diff --git a/x-pack/legacy/plugins/monitoring/public/index.scss b/x-pack/legacy/plugins/monitoring/public/index.scss deleted file mode 100644 index 41bca7774a8b8..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/index.scss +++ /dev/null @@ -1,23 +0,0 @@ -// Import the EUI global scope so we can use EUI constants -@import 'src/legacy/ui/public/styles/_styling_constants'; - -// Temporary hacks -@import 'hacks'; - -// Monitoring plugin styles - -// Prefix all styles with "mon" to avoid conflicts. -// Examples -// monChart -// monChart__legend -// monChart__legend--small -// monChart__legend-isLoading - -@import 'components/chart/index'; -@import 'components/no_data/index'; -@import 'components/sparkline/index'; -@import 'components/summary_status/index'; -@import 'components/table/index'; -@import 'components/logstash/pipeline_viewer/views/index'; -@import 'components/elasticsearch/shard_allocation/index'; -@import 'components/setup_mode/index'; diff --git a/x-pack/legacy/plugins/monitoring/public/legacy.ts b/x-pack/legacy/plugins/monitoring/public/legacy.ts deleted file mode 100644 index 293b6ac7bd821..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/legacy.ts +++ /dev/null @@ -1,27 +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 'plugins/monitoring/filters'; -import 'plugins/monitoring/services/clusters'; -import 'plugins/monitoring/services/features'; -import 'plugins/monitoring/services/executor'; -import 'plugins/monitoring/services/license'; -import 'plugins/monitoring/services/title'; -import 'plugins/monitoring/services/breadcrumbs'; -import 'plugins/monitoring/directives/all'; -import 'plugins/monitoring/views/all'; -import { npSetup, npStart } from '../public/np_imports/legacy_imports'; -import { plugin } from './np_ready'; -import { localApplicationService } from '../../../../../src/legacy/core_plugins/kibana/public/local_application_service'; - -const pluginInstance = plugin({} as any); -pluginInstance.setup(npSetup.core, npSetup.plugins); -pluginInstance.start(npStart.core, { - ...npStart.plugins, - __LEGACY: { - localApplicationService, - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/lib/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/lib/get_page_data.js deleted file mode 100644 index ae04b2d8791fa..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/lib/get_page_data.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ajaxErrorHandlersProvider } from './ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector, api) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const timeBounds = timefilter.getBounds(); - - return $http - .post(api, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js deleted file mode 100644 index 765909f0aa251..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js +++ /dev/null @@ -1,309 +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 { - coreMock, - overlayServiceMock, - notificationServiceMock, -} from '../../../../../../src/core/public/mocks'; - -let toggleSetupMode; -let initSetupModeState; -let getSetupModeState; -let updateSetupModeData; -let setSetupModeMenuItem; - -jest.mock('./ajax_error_handler', () => ({ - ajaxErrorHandlersProvider: err => { - throw err; - }, -})); - -jest.mock('react-dom', () => ({ - render: jest.fn(), -})); - -let data = {}; - -const injectorModulesMock = { - globalState: { - save: jest.fn(), - }, - Private: module => module, - $http: { - post: jest.fn().mockImplementation(() => { - return { data }; - }), - }, - $executor: { - run: jest.fn(), - }, -}; - -const angularStateMock = { - injector: { - get: module => { - return injectorModulesMock[module] || {}; - }, - }, - scope: { - $apply: fn => fn && fn(), - $evalAsync: fn => fn && fn(), - }, -}; - -// We are no longer waiting for setup mode data to be fetched when enabling -// so we need to wait for the next tick for the async action to finish -function waitForSetupModeData(action) { - process.nextTick(action); -} - -function mockFilterManager() { - let subscriber; - let filters = []; - return { - getUpdates$: () => ({ - subscribe: ({ next }) => { - subscriber = next; - return jest.fn(); - }, - }), - setFilters: newFilters => { - filters = newFilters; - subscriber(); - }, - getFilters: () => filters, - removeAll: () => { - filters = []; - subscriber(); - }, - }; -} - -const pluginData = { - query: { - filterManager: mockFilterManager(), - timefilter: { - timefilter: { - getTime: jest.fn(() => ({ from: 'now-1h', to: 'now' })), - setTime: jest.fn(), - }, - }, - }, -}; - -function setModulesAndMocks(isOnCloud = false) { - jest.clearAllMocks().resetModules(); - injectorModulesMock.globalState.inSetupMode = false; - - jest.doMock('ui/new_platform', () => ({ - npSetup: { - plugins: { - cloud: isOnCloud ? { cloudId: 'test', isCloudEnabled: true } : {}, - uiActions: { - registerAction: jest.fn(), - attachAction: jest.fn(), - }, - }, - core: { - ...coreMock.createSetup(), - notifications: notificationServiceMock.createStartContract(), - }, - }, - npStart: { - plugins: { - data: pluginData, - navigation: { ui: {} }, - }, - core: { - ...coreMock.createStart(), - overlays: overlayServiceMock.createStartContract(), - }, - }, - })); - - const setupMode = require('./setup_mode'); - toggleSetupMode = setupMode.toggleSetupMode; - initSetupModeState = setupMode.initSetupModeState; - getSetupModeState = setupMode.getSetupModeState; - updateSetupModeData = setupMode.updateSetupModeData; - setSetupModeMenuItem = setupMode.setSetupModeMenuItem; -} - -describe('setup_mode', () => { - beforeEach(async () => { - setModulesAndMocks(); - }); - - describe('setup', () => { - it('should require angular state', async () => { - let error; - try { - toggleSetupMode(true); - } catch (err) { - error = err; - } - expect(error.message).toEqual( - 'Unable to interact with setup ' + - 'mode because the angular injector was not previously set. This needs to be ' + - 'set by calling `initSetupModeState`.' - ); - }); - - it('should enable toggle mode', async () => { - initSetupModeState(angularStateMock.scope, angularStateMock.injector); - await toggleSetupMode(true); - expect(injectorModulesMock.globalState.inSetupMode).toBe(true); - }); - - it('should disable toggle mode', async () => { - initSetupModeState(angularStateMock.scope, angularStateMock.injector); - await toggleSetupMode(false); - expect(injectorModulesMock.globalState.inSetupMode).toBe(false); - }); - - it('should set top nav config', async () => { - const render = require('react-dom').render; - initSetupModeState(angularStateMock.scope, angularStateMock.injector); - setSetupModeMenuItem(); - await toggleSetupMode(true); - expect(render.mock.calls.length).toBe(2); - }); - }); - - describe('in setup mode', () => { - afterEach(async () => { - data = {}; - }); - - it('should not fetch data if on cloud', async done => { - const addDanger = jest.fn(); - data = { - _meta: { - hasPermissions: true, - }, - }; - jest.doMock('ui/notify', () => ({ - toastNotifications: { - addDanger, - }, - })); - setModulesAndMocks(true); - initSetupModeState(angularStateMock.scope, angularStateMock.injector); - await toggleSetupMode(true); - waitForSetupModeData(() => { - const state = getSetupModeState(); - expect(state.enabled).toBe(false); - expect(addDanger).toHaveBeenCalledWith({ - title: 'Setup mode is not available', - text: 'This feature is not available on cloud.', - }); - done(); - }); - }); - - it('should not fetch data if the user does not have sufficient permissions', async done => { - const addDanger = jest.fn(); - jest.doMock('ui/notify', () => ({ - toastNotifications: { - addDanger, - }, - })); - data = { - _meta: { - hasPermissions: false, - }, - }; - setModulesAndMocks(); - initSetupModeState(angularStateMock.scope, angularStateMock.injector); - await toggleSetupMode(true); - waitForSetupModeData(() => { - const state = getSetupModeState(); - expect(state.enabled).toBe(false); - expect(addDanger).toHaveBeenCalledWith({ - title: 'Setup mode is not available', - text: 'You do not have the necessary permissions to do this.', - }); - done(); - }); - }); - - it('should set the newly discovered cluster uuid', async done => { - const clusterUuid = '1ajy'; - data = { - _meta: { - liveClusterUuid: clusterUuid, - hasPermissions: true, - }, - elasticsearch: { - byUuid: { - 123: { - isPartiallyMigrated: true, - }, - }, - }, - }; - initSetupModeState(angularStateMock.scope, angularStateMock.injector); - await toggleSetupMode(true); - waitForSetupModeData(() => { - expect(injectorModulesMock.globalState.cluster_uuid).toBe(clusterUuid); - done(); - }); - }); - - it('should fetch data for a given cluster', async done => { - const clusterUuid = '1ajy'; - data = { - _meta: { - liveClusterUuid: clusterUuid, - hasPermissions: true, - }, - elasticsearch: { - byUuid: { - 123: { - isPartiallyMigrated: true, - }, - }, - }, - }; - - initSetupModeState(angularStateMock.scope, angularStateMock.injector); - await toggleSetupMode(true); - waitForSetupModeData(() => { - expect(injectorModulesMock.$http.post).toHaveBeenCalledWith( - `../api/monitoring/v1/setup/collection/cluster/${clusterUuid}`, - { - ccs: undefined, - } - ); - done(); - }); - }); - - it('should fetch data for a single node', async () => { - initSetupModeState(angularStateMock.scope, angularStateMock.injector); - await toggleSetupMode(true); - injectorModulesMock.$http.post.mockClear(); - await updateSetupModeData('45asd'); - expect(injectorModulesMock.$http.post).toHaveBeenCalledWith( - '../api/monitoring/v1/setup/collection/node/45asd', - { - ccs: undefined, - } - ); - }); - - it('should fetch data without a cluster uuid', async () => { - initSetupModeState(angularStateMock.scope, angularStateMock.injector); - await toggleSetupMode(true); - injectorModulesMock.$http.post.mockClear(); - await updateSetupModeData(undefined, true); - const url = '../api/monitoring/v1/setup/collection/cluster'; - const args = { ccs: undefined }; - expect(injectorModulesMock.$http.post).toHaveBeenCalledWith(url, args); - }); - }); -}); diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/angular_config.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/angular/angular_config.ts deleted file mode 100644 index d1849d9247985..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/angular_config.ts +++ /dev/null @@ -1,157 +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 { - ICompileProvider, - IHttpProvider, - IHttpService, - ILocationProvider, - IModule, - IRootScopeService, -} from 'angular'; -import $ from 'jquery'; -import _, { cloneDeep, forOwn, get, set } from 'lodash'; -import * as Rx from 'rxjs'; -import { CoreStart, LegacyCoreStart } from 'kibana/public'; - -const isSystemApiRequest = (request: any) => - Boolean(request && request.headers && !!request.headers['kbn-system-api']); - -export const configureAppAngularModule = (angularModule: IModule, newPlatform: LegacyCoreStart) => { - const legacyMetadata = newPlatform.injectedMetadata.getLegacyMetadata(); - - forOwn(newPlatform.injectedMetadata.getInjectedVars(), (val, name) => { - if (name !== undefined) { - // The legacy platform modifies some of these values, clone to an unfrozen object. - angularModule.value(name, cloneDeep(val)); - } - }); - - angularModule - .value('kbnVersion', newPlatform.injectedMetadata.getKibanaVersion()) - .value('buildNum', legacyMetadata.buildNum) - .value('buildSha', legacyMetadata.buildSha) - .value('serverName', legacyMetadata.serverName) - .value('esUrl', getEsUrl(newPlatform)) - .value('uiCapabilities', newPlatform.application.capabilities) - .config(setupCompileProvider(newPlatform)) - .config(setupLocationProvider()) - .config($setupXsrfRequestInterceptor(newPlatform)) - .run(capture$httpLoadingCount(newPlatform)) - .run($setupUICapabilityRedirect(newPlatform)); -}; - -const getEsUrl = (newPlatform: CoreStart) => { - const a = document.createElement('a'); - a.href = newPlatform.http.basePath.prepend('/elasticsearch'); - const protocolPort = /https/.test(a.protocol) ? 443 : 80; - const port = a.port || protocolPort; - return { - host: a.hostname, - port, - protocol: a.protocol, - pathname: a.pathname, - }; -}; - -const setupCompileProvider = (newPlatform: LegacyCoreStart) => ( - $compileProvider: ICompileProvider -) => { - if (!newPlatform.injectedMetadata.getLegacyMetadata().devMode) { - $compileProvider.debugInfoEnabled(false); - } -}; - -const setupLocationProvider = () => ($locationProvider: ILocationProvider) => { - $locationProvider.html5Mode({ - enabled: false, - requireBase: false, - rewriteLinks: false, - }); - - $locationProvider.hashPrefix(''); -}; - -const $setupXsrfRequestInterceptor = (newPlatform: LegacyCoreStart) => { - const version = newPlatform.injectedMetadata.getLegacyMetadata().version; - - // Configure jQuery prefilter - $.ajaxPrefilter(({ kbnXsrfToken = true }: any, originalOptions, jqXHR) => { - if (kbnXsrfToken) { - jqXHR.setRequestHeader('kbn-version', version); - } - }); - - return ($httpProvider: IHttpProvider) => { - // Configure $httpProvider interceptor - $httpProvider.interceptors.push(() => { - return { - request(opts) { - const { kbnXsrfToken = true } = opts as any; - if (kbnXsrfToken) { - set(opts, ['headers', 'kbn-version'], version); - } - return opts; - }, - }; - }); - }; -}; - -/** - * Injected into angular module by ui/chrome angular integration - * and adds a root-level watcher that will capture the count of - * active $http requests on each digest loop and expose the count to - * the core.loadingCount api - * @param {Angular.Scope} $rootScope - * @param {HttpService} $http - * @return {undefined} - */ -const capture$httpLoadingCount = (newPlatform: CoreStart) => ( - $rootScope: IRootScopeService, - $http: IHttpService -) => { - newPlatform.http.addLoadingCountSource( - new Rx.Observable(observer => { - const unwatch = $rootScope.$watch(() => { - const reqs = $http.pendingRequests || []; - observer.next(reqs.filter(req => !isSystemApiRequest(req)).length); - }); - - return unwatch; - }) - ); -}; - -/** - * integrates with angular to automatically redirect to home if required - * capability is not met - */ -const $setupUICapabilityRedirect = (newPlatform: CoreStart) => ( - $rootScope: IRootScopeService, - $injector: any -) => { - const isKibanaAppRoute = window.location.pathname.endsWith('/app/kibana'); - // this feature only works within kibana app for now after everything is - // switched to the application service, this can be changed to handle all - // apps. - if (!isKibanaAppRoute) { - return; - } - $rootScope.$on( - '$routeChangeStart', - (event, { $$route: route }: { $$route?: { requireUICapability: boolean } } = {}) => { - if (!route || !route.requireUICapability) { - return; - } - - if (!get(newPlatform.application.capabilities, route.requireUICapability)) { - $injector.get('kbnUrl').change('/home'); - event.preventDefault(); - } - } - ); -}; diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/index.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/angular/index.ts deleted file mode 100644 index 8fd8d170bbb40..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/index.ts +++ /dev/null @@ -1,48 +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 angular, { IModule } from 'angular'; - -import { AppMountContext, LegacyCoreStart } from 'kibana/public'; - -// @ts-ignore TODO: change to absolute path -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -// @ts-ignore TODO: change to absolute path -import chrome from 'plugins/monitoring/np_imports/ui/chrome'; -// @ts-ignore TODO: change to absolute path -import { uiModules } from 'plugins/monitoring/np_imports/ui/modules'; -// @ts-ignore TODO: change to absolute path -import { registerTimefilterWithGlobalState } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { configureAppAngularModule } from './angular_config'; - -import { localAppModule, appModuleName } from './modules'; - -export class AngularApp { - private injector?: angular.auto.IInjectorService; - - constructor({ core }: AppMountContext, { element }: { element: HTMLElement }) { - uiModules.addToModule(); - const app: IModule = localAppModule(core); - app.config(($routeProvider: any) => { - $routeProvider.eagerInstantiationEnabled(false); - uiRoutes.addToProvider($routeProvider); - }); - configureAppAngularModule(app, core as LegacyCoreStart); - registerTimefilterWithGlobalState(app); - const appElement = document.createElement('div'); - appElement.setAttribute('style', 'height: 100%'); - appElement.innerHTML = '<div ng-view style="height: 100%" id="monitoring-angular-app"></div>'; - this.injector = angular.bootstrap(appElement, [appModuleName]); - chrome.setInjector(this.injector); - angular.element(element).append(appElement); - } - - public destroy = () => { - if (this.injector) { - this.injector.get('$rootScope').$destroy(); - } - }; -} diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/modules.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/angular/modules.ts deleted file mode 100644 index c6031cb220334..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/modules.ts +++ /dev/null @@ -1,148 +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 angular, { IWindowService } from 'angular'; -// required for `ngSanitize` angular module -import 'angular-sanitize'; -import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; - -import { AppMountContext } from 'kibana/public'; -import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; -import { - createTopNavDirective, - createTopNavHelper, -} from '../../../../../../../src/plugins/kibana_legacy/public'; - -import { - GlobalStateProvider, - StateManagementConfigProvider, - AppStateProvider, - KbnUrlProvider, - npStart, -} from '../legacy_imports'; - -// @ts-ignore -import { PromiseServiceCreator } from './providers/promises'; -// @ts-ignore -import { PrivateProvider } from './providers/private'; -import { getSafeForExternalLink } from '../../lib/get_safe_for_external_link'; - -type IPrivate = <T>(provider: (...injectable: any[]) => T) => T; - -export const appModuleName = 'monitoring'; -const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; - -export const localAppModule = (core: AppMountContext['core']) => { - createLocalI18nModule(); - createLocalPrivateModule(); - createLocalPromiseModule(); - createLocalStorage(); - createLocalConfigModule(core); - createLocalKbnUrlModule(); - createLocalStateModule(); - createLocalTopNavModule(npStart.plugins.navigation); - createHrefModule(core); - - const appModule = angular.module(appModuleName, [ - ...thirdPartyAngularDependencies, - 'monitoring/Config', - 'monitoring/I18n', - 'monitoring/Private', - 'monitoring/TopNav', - 'monitoring/State', - 'monitoring/Storage', - 'monitoring/href', - 'monitoring/services', - 'monitoring/filters', - 'monitoring/directives', - ]); - return appModule; -}; - -function createLocalStateModule() { - angular - .module('monitoring/State', [ - 'monitoring/Private', - 'monitoring/Config', - 'monitoring/KbnUrl', - 'monitoring/Promise', - ]) - .factory('AppState', function(Private: IPrivate) { - return Private(AppStateProvider); - }) - .service('globalState', function(Private: IPrivate) { - return Private(GlobalStateProvider); - }); -} - -function createLocalKbnUrlModule() { - angular - .module('monitoring/KbnUrl', ['monitoring/Private', 'ngRoute']) - .service('kbnUrl', (Private: IPrivate) => Private(KbnUrlProvider)); -} - -function createLocalConfigModule(core: AppMountContext['core']) { - angular - .module('monitoring/Config', ['monitoring/Private']) - .provider('stateManagementConfig', StateManagementConfigProvider) - .provider('config', () => { - return { - $get: () => ({ - get: core.uiSettings.get.bind(core.uiSettings), - }), - }; - }); -} - -function createLocalPromiseModule() { - angular.module('monitoring/Promise', []).service('Promise', PromiseServiceCreator); -} - -function createLocalStorage() { - angular - .module('monitoring/Storage', []) - .service('localStorage', ($window: IWindowService) => new Storage($window.localStorage)) - .service('sessionStorage', ($window: IWindowService) => new Storage($window.sessionStorage)) - .service('sessionTimeout', () => {}); -} - -function createLocalPrivateModule() { - angular.module('monitoring/Private', []).provider('Private', PrivateProvider); -} - -function createLocalTopNavModule({ ui }: any) { - angular - .module('monitoring/TopNav', ['react']) - .directive('kbnTopNav', createTopNavDirective) - .directive('kbnTopNavHelper', createTopNavHelper(ui)); -} - -function createLocalI18nModule() { - angular - .module('monitoring/I18n', []) - .provider('i18n', I18nProvider) - .filter('i18n', i18nFilter) - .directive('i18nId', i18nDirective); -} - -function createHrefModule(core: AppMountContext['core']) { - const name: string = 'kbnHref'; - angular.module('monitoring/href', []).directive(name, () => { - return { - restrict: 'A', - link: { - pre: (_$scope, _$el, $attr) => { - $attr.$observe(name, val => { - if (val) { - const url = getSafeForExternalLink(val as string); - $attr.$set('href', core.http.basePath.prepend(url)); - } - }); - }, - }, - }; - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/providers/promises.js b/x-pack/legacy/plugins/monitoring/public/np_imports/angular/providers/promises.js deleted file mode 100644 index 22adccaf3db7f..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/angular/providers/promises.js +++ /dev/null @@ -1,116 +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 _ from 'lodash'; - -export function PromiseServiceCreator($q, $timeout) { - function Promise(fn) { - if (typeof this === 'undefined') - throw new Error('Promise constructor must be called with "new"'); - - const defer = $q.defer(); - try { - fn(defer.resolve, defer.reject); - } catch (e) { - defer.reject(e); - } - return defer.promise; - } - - Promise.all = Promise.props = $q.all; - Promise.resolve = function(val) { - const defer = $q.defer(); - defer.resolve(val); - return defer.promise; - }; - Promise.reject = function(reason) { - const defer = $q.defer(); - defer.reject(reason); - return defer.promise; - }; - Promise.cast = $q.when; - Promise.delay = function(ms) { - return $timeout(_.noop, ms); - }; - Promise.method = function(fn) { - return function() { - const args = Array.prototype.slice.call(arguments); - return Promise.try(fn, args, this); - }; - }; - Promise.nodeify = function(promise, cb) { - promise.then(function(val) { - cb(void 0, val); - }, cb); - }; - Promise.map = function(arr, fn) { - return Promise.all( - arr.map(function(i, el, list) { - return Promise.try(fn, [i, el, list]); - }) - ); - }; - Promise.each = function(arr, fn) { - const queue = arr.slice(0); - let i = 0; - return (function next() { - if (!queue.length) return arr; - return Promise.try(fn, [arr.shift(), i++]).then(next); - })(); - }; - Promise.is = function(obj) { - // $q doesn't create instances of any constructor, promises are just objects with a then function - // https://github.com/angular/angular.js/blob/58f5da86645990ef984353418cd1ed83213b111e/src/ng/q.js#L335 - return obj && typeof obj.then === 'function'; - }; - Promise.halt = _.once(function() { - const promise = new Promise(() => {}); - promise.then = _.constant(promise); - promise.catch = _.constant(promise); - return promise; - }); - Promise.try = function(fn, args, ctx) { - if (typeof fn !== 'function') { - return Promise.reject(new TypeError('fn must be a function')); - } - - let value; - - if (Array.isArray(args)) { - try { - value = fn.apply(ctx, args); - } catch (e) { - return Promise.reject(e); - } - } else { - try { - value = fn.call(ctx, args); - } catch (e) { - return Promise.reject(e); - } - } - - return Promise.resolve(value); - }; - Promise.fromNode = function(takesCbFn) { - return new Promise(function(resolve, reject) { - takesCbFn(function(err, ...results) { - if (err) reject(err); - else if (results.length > 1) resolve(results); - else resolve(results[0]); - }); - }); - }; - Promise.race = function(iterable) { - return new Promise((resolve, reject) => { - for (const i of iterable) { - Promise.resolve(i).then(resolve, reject); - } - }); - }; - - return Promise; -} diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/legacy_imports.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/legacy_imports.ts deleted file mode 100644 index 208b7e2acdb0f..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/legacy_imports.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/** - * Last remaining 'ui/*' imports that will eventually be shimmed with their np alternatives - */ - -export { npSetup, npStart } from 'ui/new_platform'; -// @ts-ignore -export { GlobalStateProvider } from 'ui/state_management/global_state'; -// @ts-ignore -export { StateManagementConfigProvider } from 'ui/state_management/config_provider'; -// @ts-ignore -export { AppStateProvider } from 'ui/state_management/app_state'; -// @ts-ignore -export { EventsProvider } from 'ui/events'; -// @ts-ignore -export { KbnUrlProvider } from 'ui/url'; -export { registerTimefilterWithGlobalStateFactory } from 'ui/timefilter/setup_router'; diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/capabilities.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/ui/capabilities.ts deleted file mode 100644 index 5aff302501401..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/capabilities.ts +++ /dev/null @@ -1,8 +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 { npStart } from '../legacy_imports'; -export const capabilities = { get: () => npStart.core.application.capabilities }; diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/chrome.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/ui/chrome.ts deleted file mode 100644 index f0c5bacabecbf..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/chrome.ts +++ /dev/null @@ -1,33 +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 angular from 'angular'; -import { npStart, npSetup } from '../legacy_imports'; - -type OptionalInjector = void | angular.auto.IInjectorService; - -class Chrome { - private injector?: OptionalInjector; - - public setInjector = (injector: OptionalInjector): void => void (this.injector = injector); - public dangerouslyGetActiveInjector = (): OptionalInjector => this.injector; - - public getBasePath = (): string => npStart.core.http.basePath.get(); - - public getInjected = (name?: string, defaultValue?: any): string | unknown => { - const { getInjectedVar, getInjectedVars } = npSetup.core.injectedMetadata; - return name ? getInjectedVar(name, defaultValue) : getInjectedVars(); - }; - - public get breadcrumbs() { - const set = (...args: any[]) => npStart.core.chrome.setBreadcrumbs.apply(this, args as any); - return { set }; - } -} - -const chrome = new Chrome(); - -export default chrome; // eslint-disable-line import/no-default-export diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/modules.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/ui/modules.ts deleted file mode 100644 index 70201a7906110..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/modules.ts +++ /dev/null @@ -1,55 +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 angular from 'angular'; - -type PrivateProvider = (...args: any) => any; -interface Provider { - name: string; - provider: PrivateProvider; -} - -class Modules { - private _services: Provider[] = []; - private _filters: Provider[] = []; - private _directives: Provider[] = []; - - public get = (_name: string, _dep?: string[]) => { - return this; - }; - - public service = (...args: any) => { - this._services.push(args); - }; - - public filter = (...args: any) => { - this._filters.push(args); - }; - - public directive = (...args: any) => { - this._directives.push(args); - }; - - public addToModule = () => { - angular.module('monitoring/services', []); - angular.module('monitoring/filters', []); - angular.module('monitoring/directives', []); - - this._services.forEach(args => { - angular.module('monitoring/services').service.apply(null, args as any); - }); - - this._filters.forEach(args => { - angular.module('monitoring/filters').filter.apply(null, args as any); - }); - - this._directives.forEach(args => { - angular.module('monitoring/directives').directive.apply(null, args as any); - }); - }; -} - -export const uiModules = new Modules(); diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/routes.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/ui/routes.ts deleted file mode 100644 index 22da56a8d184a..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/routes.ts +++ /dev/null @@ -1,39 +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. - */ - -type RouteObject = [string, any]; -interface Redirect { - redirectTo: string; -} - -class Routes { - private _routes: RouteObject[] = []; - private _redirect?: Redirect; - - public when = (...args: RouteObject) => { - const [, routeOptions] = args; - routeOptions.reloadOnSearch = false; - this._routes.push(args); - return this; - }; - - public otherwise = (redirect: Redirect) => { - this._redirect = redirect; - return this; - }; - - public addToProvider = ($routeProvider: any) => { - this._routes.forEach(args => { - $routeProvider.when.apply(this, args); - }); - - if (this._redirect) { - $routeProvider.otherwise(this._redirect); - } - }; -} -const uiRoutes = new Routes(); -export default uiRoutes; // eslint-disable-line import/no-default-export diff --git a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/timefilter.ts b/x-pack/legacy/plugins/monitoring/public/np_imports/ui/timefilter.ts deleted file mode 100644 index e28699bd126b9..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_imports/ui/timefilter.ts +++ /dev/null @@ -1,31 +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 { IModule, IRootScopeService } from 'angular'; -import { npStart, registerTimefilterWithGlobalStateFactory } from '../legacy_imports'; - -const { - core: { uiSettings }, -} = npStart; -export const { timefilter } = npStart.plugins.data.query.timefilter; - -uiSettings.overrideLocalDefault( - 'timepicker:refreshIntervalDefaults', - JSON.stringify({ value: 10000, pause: false }) -); -uiSettings.overrideLocalDefault( - 'timepicker:timeDefaults', - JSON.stringify({ from: 'now-1h', to: 'now' }) -); - -export const registerTimefilterWithGlobalState = (app: IModule) => { - app.run((globalState: any, $rootScope: IRootScopeService) => { - globalState.fetch(); - globalState.$inheritedGlobalState = true; - globalState.save(); - registerTimefilterWithGlobalStateFactory(timefilter, globalState, $rootScope); - }); -}; diff --git a/x-pack/legacy/plugins/monitoring/public/np_ready/index.ts b/x-pack/legacy/plugins/monitoring/public/np_ready/index.ts deleted file mode 100644 index 80848c497c370..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_ready/index.ts +++ /dev/null @@ -1,12 +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 { PluginInitializerContext } from 'src/core/public'; -import { MonitoringPlugin } from './plugin'; - -export function plugin(ctx: PluginInitializerContext) { - return new MonitoringPlugin(ctx); -} diff --git a/x-pack/legacy/plugins/monitoring/public/np_ready/plugin.ts b/x-pack/legacy/plugins/monitoring/public/np_ready/plugin.ts deleted file mode 100644 index 5598a7a51cf42..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/np_ready/plugin.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { App, CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'kibana/public'; - -export class MonitoringPlugin implements Plugin { - constructor(ctx: PluginInitializerContext) {} - - public setup(core: CoreSetup, plugins: any) { - const app: App = { - id: 'monitoring', - title: 'Monitoring', - mount: async (context, params) => { - const { AngularApp } = await import('../np_imports/angular'); - const monitoringApp = new AngularApp(context, params); - return monitoringApp.destroy; - }, - }; - - core.application.register(app); - } - - public start(core: CoreStart, plugins: any) {} - public stop() {} -} diff --git a/x-pack/legacy/plugins/monitoring/public/register_feature.ts b/x-pack/legacy/plugins/monitoring/public/register_feature.ts deleted file mode 100644 index 9b72e01a19394..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/register_feature.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import chrome from 'ui/chrome'; -import { npSetup } from 'ui/new_platform'; -import { FeatureCatalogueCategory } from '../../../../../src/plugins/home/public'; - -const { - plugins: { home }, -} = npSetup; - -if (chrome.getInjected('monitoringUiEnabled')) { - home.featureCatalogue.register({ - id: 'monitoring', - title: i18n.translate('xpack.monitoring.monitoringTitle', { - defaultMessage: 'Monitoring', - }), - description: i18n.translate('xpack.monitoring.monitoringDescription', { - defaultMessage: 'Track the real-time health and performance of your Elastic Stack.', - }), - icon: 'monitoringApp', - path: '/app/monitoring', - showOnHomePage: true, - category: FeatureCatalogueCategory.ADMIN, - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/services/__tests__/breadcrumbs.js b/x-pack/legacy/plugins/monitoring/public/services/__tests__/breadcrumbs.js deleted file mode 100644 index e5b2e01373340..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/services/__tests__/breadcrumbs.js +++ /dev/null @@ -1,130 +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 expect from '@kbn/expect'; -import { breadcrumbsProvider } from '../breadcrumbs_provider'; -import { MonitoringMainController } from 'plugins/monitoring/directives/main'; - -describe('Monitoring Breadcrumbs Service', () => { - it('in Cluster Alerts', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - name: 'alerts', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters' }, - { url: '#/overview', label: 'test-cluster-foo' }, - ]); - }); - - it('in Cluster Overview', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - name: 'overview', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters' }, - ]); - }); - - it('in ES Node - Advanced', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - product: 'elasticsearch', - name: 'nodes', - instance: 'es-node-name-01', - resolver: 'es-node-resolver-01', - page: 'advanced', - tabIconClass: 'fa star', - tabIconLabel: 'Master Node', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters' }, - { url: '#/overview', label: 'test-cluster-foo' }, - { url: '#/elasticsearch', label: 'Elasticsearch' }, - { url: '#/elasticsearch/nodes', label: 'Nodes', testSubj: 'breadcrumbEsNodes' }, - { url: null, label: 'es-node-name-01' }, - ]); - }); - - it('in Kibana Overview', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - product: 'kibana', - name: 'overview', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters' }, - { url: '#/overview', label: 'test-cluster-foo' }, - { url: null, label: 'Kibana' }, - ]); - }); - - /** - * <monitoring-main product="logstash" name="nodes"> - */ - it('in Logstash Listing', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - product: 'logstash', - name: 'listing', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters' }, - { url: '#/overview', label: 'test-cluster-foo' }, - { url: null, label: 'Logstash' }, - ]); - }); - - /** - * <monitoring-main product="logstash" page="pipeline"> - */ - it('in Logstash Pipeline Viewer', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - product: 'logstash', - page: 'pipeline', - pipelineId: 'main', - pipelineHash: '42ee890af9...', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters' }, - { url: '#/overview', label: 'test-cluster-foo' }, - { url: '#/logstash', label: 'Logstash' }, - { url: '#/logstash/pipelines', label: 'Pipelines' }, - ]); - }); -}); diff --git a/x-pack/legacy/plugins/monitoring/public/services/__tests__/executor_provider.js b/x-pack/legacy/plugins/monitoring/public/services/__tests__/executor_provider.js deleted file mode 100644 index 2c4d49716406c..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/services/__tests__/executor_provider.js +++ /dev/null @@ -1,103 +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 ngMock from 'ng_mock'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; -import { executorProvider } from '../executor_provider'; -import Bluebird from 'bluebird'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -describe('$executor service', () => { - let scope; - let executor; - let $timeout; - - beforeEach(ngMock.module('kibana')); - - beforeEach( - ngMock.inject(function(_$rootScope_) { - scope = _$rootScope_.$new(); - }) - ); - - beforeEach(() => { - $timeout = sinon.spy(setTimeout); - $timeout.cancel = id => clearTimeout(id); - - timefilter.setRefreshInterval({ - value: 0, - }); - - executor = executorProvider(Bluebird, $timeout); - }); - - afterEach(() => executor.destroy()); - - it('should not call $timeout if the timefilter is not paused and set to zero', () => { - executor.start(scope); - expect($timeout.callCount).to.equal(0); - }); - - it('should call $timeout if the timefilter is not paused and set to 1000ms', () => { - timefilter.setRefreshInterval({ - pause: false, - value: 1000, - }); - executor.start(scope); - expect($timeout.callCount).to.equal(1); - }); - - it('should execute function if timefilter is not paused and interval set to 1000ms', done => { - timefilter.setRefreshInterval({ - pause: false, - value: 1000, - }); - executor.register({ execute: () => Bluebird.resolve().then(() => done(), done) }); - executor.start(scope); - }); - - it('should execute function multiple times', done => { - let calls = 0; - timefilter.setRefreshInterval({ - pause: false, - value: 10, - }); - executor.register({ - execute: () => { - if (calls++ > 1) { - done(); - } - return Bluebird.resolve(); - }, - }); - executor.start(scope); - }); - - it('should call handleResponse', done => { - timefilter.setRefreshInterval({ - pause: false, - value: 10, - }); - executor.register({ - execute: () => Bluebird.resolve(), - handleResponse: () => done(), - }); - executor.start(scope); - }); - - it('should call handleError', done => { - timefilter.setRefreshInterval({ - pause: false, - value: 10, - }); - executor.register({ - execute: () => Bluebird.reject(new Error('reject test')), - handleError: () => done(), - }); - executor.start(scope); - }); -}); diff --git a/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs.js b/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs.js deleted file mode 100644 index d0fe600386307..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs.js +++ /dev/null @@ -1,10 +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 { uiModules } from 'plugins/monitoring/np_imports/ui/modules'; -import { breadcrumbsProvider } from './breadcrumbs_provider'; -const uiModule = uiModules.get('monitoring/breadcrumbs', []); -uiModule.service('breadcrumbs', breadcrumbsProvider); diff --git a/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs_provider.js b/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs_provider.js deleted file mode 100644 index 7917606a5bc8e..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/services/breadcrumbs_provider.js +++ /dev/null @@ -1,208 +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 chrome from 'plugins/monitoring/np_imports/ui/chrome'; -import { i18n } from '@kbn/i18n'; - -// Helper for making objects to use in a link element -const createCrumb = (url, label, testSubj) => { - const crumb = { url, label }; - if (testSubj) { - crumb.testSubj = testSubj; - } - return crumb; -}; - -// generate Elasticsearch breadcrumbs -function getElasticsearchBreadcrumbs(mainInstance) { - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/elasticsearch', 'Elasticsearch')); - if (mainInstance.name === 'indices') { - breadcrumbs.push( - createCrumb( - '#/elasticsearch/indices', - i18n.translate('xpack.monitoring.breadcrumbs.es.indicesLabel', { - defaultMessage: 'Indices', - }), - 'breadcrumbEsIndices' - ) - ); - } else if (mainInstance.name === 'nodes') { - breadcrumbs.push( - createCrumb( - '#/elasticsearch/nodes', - i18n.translate('xpack.monitoring.breadcrumbs.es.nodesLabel', { defaultMessage: 'Nodes' }), - 'breadcrumbEsNodes' - ) - ); - } else if (mainInstance.name === 'ml') { - // ML Instance (for user later) - breadcrumbs.push( - createCrumb( - '#/elasticsearch/ml_jobs', - i18n.translate('xpack.monitoring.breadcrumbs.es.jobsLabel', { defaultMessage: 'Jobs' }) - ) - ); - } else if (mainInstance.name === 'ccr_shard') { - breadcrumbs.push( - createCrumb( - '#/elasticsearch/ccr', - i18n.translate('xpack.monitoring.breadcrumbs.es.ccrLabel', { defaultMessage: 'CCR' }) - ) - ); - } - breadcrumbs.push(createCrumb(null, mainInstance.instance)); - } else { - // don't link to Overview when we're possibly on Overview or its sibling tabs - breadcrumbs.push(createCrumb(null, 'Elasticsearch')); - } - return breadcrumbs; -} - -// generate Kibana breadcrumbs -function getKibanaBreadcrumbs(mainInstance) { - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/kibana', 'Kibana')); - breadcrumbs.push( - createCrumb( - '#/kibana/instances', - i18n.translate('xpack.monitoring.breadcrumbs.kibana.instancesLabel', { - defaultMessage: 'Instances', - }) - ) - ); - } else { - // don't link to Overview when we're possibly on Overview or its sibling tabs - breadcrumbs.push(createCrumb(null, 'Kibana')); - } - return breadcrumbs; -} - -// generate Logstash breadcrumbs -function getLogstashBreadcrumbs(mainInstance) { - const logstashLabel = i18n.translate('xpack.monitoring.breadcrumbs.logstashLabel', { - defaultMessage: 'Logstash', - }); - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/logstash', logstashLabel)); - if (mainInstance.name === 'nodes') { - breadcrumbs.push( - createCrumb( - '#/logstash/nodes', - i18n.translate('xpack.monitoring.breadcrumbs.logstash.nodesLabel', { - defaultMessage: 'Nodes', - }) - ) - ); - } - breadcrumbs.push(createCrumb(null, mainInstance.instance)); - } else if (mainInstance.page === 'pipeline') { - breadcrumbs.push(createCrumb('#/logstash', logstashLabel)); - breadcrumbs.push( - createCrumb( - '#/logstash/pipelines', - i18n.translate('xpack.monitoring.breadcrumbs.logstash.pipelinesLabel', { - defaultMessage: 'Pipelines', - }) - ) - ); - } else { - // don't link to Overview when we're possibly on Overview or its sibling tabs - breadcrumbs.push(createCrumb(null, logstashLabel)); - } - - return breadcrumbs; -} - -// generate Beats breadcrumbs -function getBeatsBreadcrumbs(mainInstance) { - const beatsLabel = i18n.translate('xpack.monitoring.breadcrumbs.beatsLabel', { - defaultMessage: 'Beats', - }); - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/beats', beatsLabel)); - breadcrumbs.push( - createCrumb( - '#/beats/beats', - i18n.translate('xpack.monitoring.breadcrumbs.beats.instancesLabel', { - defaultMessage: 'Instances', - }) - ) - ); - breadcrumbs.push(createCrumb(null, mainInstance.instance)); - } else { - breadcrumbs.push(createCrumb(null, beatsLabel)); - } - - return breadcrumbs; -} - -// generate Apm breadcrumbs -function getApmBreadcrumbs(mainInstance) { - const apmLabel = i18n.translate('xpack.monitoring.breadcrumbs.apmLabel', { - defaultMessage: 'APM', - }); - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/apm', apmLabel)); - breadcrumbs.push( - createCrumb( - '#/apm/instances', - i18n.translate('xpack.monitoring.breadcrumbs.apm.instancesLabel', { - defaultMessage: 'Instances', - }) - ) - ); - } else { - // don't link to Overview when we're possibly on Overview or its sibling tabs - breadcrumbs.push(createCrumb(null, apmLabel)); - } - return breadcrumbs; -} - -export function breadcrumbsProvider() { - return function createBreadcrumbs(clusterName, mainInstance) { - const homeCrumb = i18n.translate('xpack.monitoring.breadcrumbs.clustersLabel', { - defaultMessage: 'Clusters', - }); - - let breadcrumbs = [createCrumb('#/home', homeCrumb, 'breadcrumbClusters')]; - - if (!mainInstance.inOverview && clusterName) { - breadcrumbs.push(createCrumb('#/overview', clusterName)); - } - - if (mainInstance.inElasticsearch) { - breadcrumbs = breadcrumbs.concat(getElasticsearchBreadcrumbs(mainInstance)); - } - if (mainInstance.inKibana) { - breadcrumbs = breadcrumbs.concat(getKibanaBreadcrumbs(mainInstance)); - } - if (mainInstance.inLogstash) { - breadcrumbs = breadcrumbs.concat(getLogstashBreadcrumbs(mainInstance)); - } - if (mainInstance.inBeats) { - breadcrumbs = breadcrumbs.concat(getBeatsBreadcrumbs(mainInstance)); - } - if (mainInstance.inApm) { - breadcrumbs = breadcrumbs.concat(getApmBreadcrumbs(mainInstance)); - } - - chrome.breadcrumbs.set( - breadcrumbs.map(b => ({ - text: b.label, - href: b.url, - 'data-test-subj': b.testSubj, - })) - ); - - return breadcrumbs; - }; -} diff --git a/x-pack/legacy/plugins/monitoring/public/services/executor.js b/x-pack/legacy/plugins/monitoring/public/services/executor.js deleted file mode 100644 index 5004cd0238012..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/services/executor.js +++ /dev/null @@ -1,10 +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 { uiModules } from 'plugins/monitoring/np_imports/ui/modules'; -import { executorProvider } from './executor_provider'; -const uiModule = uiModules.get('monitoring/executor', []); -uiModule.service('$executor', executorProvider); diff --git a/x-pack/legacy/plugins/monitoring/public/services/executor_provider.js b/x-pack/legacy/plugins/monitoring/public/services/executor_provider.js deleted file mode 100644 index 4a0551fa5af11..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/services/executor_provider.js +++ /dev/null @@ -1,122 +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 { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { subscribeWithScope } from 'plugins/monitoring/np_imports/ui/utils'; -import { Subscription } from 'rxjs'; -export function executorProvider(Promise, $timeout) { - const queue = []; - const subscriptions = new Subscription(); - let executionTimer; - let ignorePaused = false; - - /** - * Resets the timer to start again - * @returns {void} - */ - function reset() { - cancel(); - start(); - } - - function killTimer() { - if (executionTimer) { - $timeout.cancel(executionTimer); - } - } - - /** - * Cancels the execution timer - * @returns {void} - */ - function cancel() { - killTimer(); - } - - /** - * Registers a service with the executor - * @param {object} service The service to register - * @returns {void} - */ - function register(service) { - queue.push(service); - } - - /** - * Stops the executor and empties the service queue - * @returns {void} - */ - function destroy() { - subscriptions.unsubscribe(); - cancel(); - ignorePaused = false; - queue.splice(0, queue.length); - } - - /** - * Runs the queue (all at once) - * @returns {Promise} a promise of all the services - */ - function run() { - const noop = () => Promise.resolve(); - return Promise.all( - queue.map(service => { - return service - .execute() - .then(service.handleResponse || noop) - .catch(service.handleError || noop); - }) - ).finally(reset); - } - - function reFetch() { - cancel(); - run(); - } - - function killIfPaused() { - if (timefilter.getRefreshInterval().pause) { - killTimer(); - } - } - - /** - * Starts the executor service if the timefilter is not paused - * @returns {void} - */ - function start() { - if ( - (ignorePaused || timefilter.getRefreshInterval().pause === false) && - timefilter.getRefreshInterval().value > 0 - ) { - executionTimer = $timeout(run, timefilter.getRefreshInterval().value); - } - } - - /** - * Expose the methods - */ - return { - register, - start($scope) { - subscriptions.add( - subscribeWithScope($scope, timefilter.getFetch$(), { - next: reFetch, - }) - ); - subscriptions.add( - subscribeWithScope($scope, timefilter.getRefreshIntervalUpdate$(), { - next: killIfPaused, - }) - ); - start(); - }, - run, - destroy, - reset, - cancel, - }; -} diff --git a/x-pack/legacy/plugins/monitoring/public/services/title.js b/x-pack/legacy/plugins/monitoring/public/services/title.js deleted file mode 100644 index 442f4fb5b4029..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/services/title.js +++ /dev/null @@ -1,26 +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 _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { uiModules } from 'plugins/monitoring/np_imports/ui/modules'; -import { docTitle } from 'ui/doc_title'; - -const uiModule = uiModules.get('monitoring/title', []); -uiModule.service('title', () => { - return function changeTitle(cluster, suffix) { - let clusterName = _.get(cluster, 'cluster_name'); - clusterName = clusterName ? `- ${clusterName}` : ''; - suffix = suffix ? `- ${suffix}` : ''; - docTitle.change( - i18n.translate('xpack.monitoring.stackMonitoringDocTitle', { - defaultMessage: 'Stack Monitoring {clusterName} {suffix}', - values: { clusterName, suffix }, - }), - true - ); - }; -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/__tests__/base_controller.js b/x-pack/legacy/plugins/monitoring/public/views/__tests__/base_controller.js deleted file mode 100644 index 6c3c73a35601c..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/__tests__/base_controller.js +++ /dev/null @@ -1,204 +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 { spy, stub } from 'sinon'; -import expect from '@kbn/expect'; -import { MonitoringViewBaseController } from '../'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { PromiseWithCancel, Status } from '../../../common/cancel_promise'; - -/* - * Mostly copied from base_table_controller test, with modifications - */ -describe('MonitoringViewBaseController', function() { - let ctrl; - let $injector; - let $scope; - let opts; - let titleService; - let executorService; - let configService; - const httpCall = ms => new Promise(resolve => setTimeout(() => resolve(), ms)); - - before(() => { - titleService = spy(); - executorService = { - register: spy(), - start: spy(), - cancel: spy(), - run: spy(), - }; - configService = { - get: spy(), - }; - - const windowMock = () => { - const events = {}; - const targetEvent = 'popstate'; - return { - removeEventListener: stub(), - addEventListener: (name, handler) => name === targetEvent && (events[name] = handler), - history: { - back: () => events[targetEvent] && events[targetEvent](), - }, - }; - }; - - const injectorGetStub = stub(); - injectorGetStub.withArgs('title').returns(titleService); - injectorGetStub.withArgs('$executor').returns(executorService); - injectorGetStub - .withArgs('localStorage') - .throws('localStorage should not be used by this class'); - injectorGetStub.withArgs('$window').returns(windowMock()); - injectorGetStub.withArgs('config').returns(configService); - $injector = { get: injectorGetStub }; - - $scope = { - cluster: { cluster_uuid: 'foo' }, - $on: stub(), - $apply: stub(), - }; - - opts = { - title: 'testo', - getPageData: () => Promise.resolve({ data: { test: true } }), - $injector, - $scope, - }; - - ctrl = new MonitoringViewBaseController(opts); - }); - - it('show/hide zoom-out button based on interaction', done => { - const xaxis = { from: 1562089923880, to: 1562090159676 }; - const timeRange = { xaxis }; - const { zoomInfo } = ctrl; - - ctrl.onBrush(timeRange); - - expect(zoomInfo.showZoomOutBtn()).to.be(true); - - /* - Need to do this async, since we are delaying event adding - */ - setTimeout(() => { - zoomInfo.zoomOutHandler(); - expect(zoomInfo.showZoomOutBtn()).to.be(false); - done(); - }, 15); - }); - - it('creates functions for fetching data', () => { - expect(ctrl.updateData).to.be.a('function'); - expect(ctrl.onBrush).to.be.a('function'); - }); - - it('sets page title', () => { - expect(titleService.calledOnce).to.be(true); - const { args } = titleService.getCall(0); - expect(args).to.eql([{ cluster_uuid: 'foo' }, 'testo']); - }); - - it('starts data poller', () => { - expect(executorService.register.calledOnce).to.be(true); - expect(executorService.start.calledOnce).to.be(true); - }); - - it('does not allow for a new request if one is inflight', done => { - let counter = 0; - const opts = { - title: 'testo', - getPageData: ms => httpCall(ms), - $injector, - $scope, - }; - - const ctrl = new MonitoringViewBaseController(opts); - ctrl.updateData(30).then(() => ++counter); - ctrl.updateData(60).then(() => { - expect(counter).to.be(0); - done(); - }); - }); - - describe('time filter', () => { - it('enables timepicker and auto refresh #1', () => { - expect(timefilter.isTimeRangeSelectorEnabled()).to.be(true); - expect(timefilter.isAutoRefreshSelectorEnabled()).to.be(true); - }); - - it('enables timepicker and auto refresh #2', () => { - opts = { - ...opts, - options: {}, - }; - ctrl = new MonitoringViewBaseController(opts); - - expect(timefilter.isTimeRangeSelectorEnabled()).to.be(true); - expect(timefilter.isAutoRefreshSelectorEnabled()).to.be(true); - }); - - it('disables timepicker and enables auto refresh', () => { - opts = { - ...opts, - options: { enableTimeFilter: false }, - }; - ctrl = new MonitoringViewBaseController(opts); - - expect(timefilter.isTimeRangeSelectorEnabled()).to.be(false); - expect(timefilter.isAutoRefreshSelectorEnabled()).to.be(true); - }); - - it('enables timepicker and disables auto refresh', () => { - opts = { - ...opts, - options: { enableAutoRefresh: false }, - }; - ctrl = new MonitoringViewBaseController(opts); - - expect(timefilter.isTimeRangeSelectorEnabled()).to.be(true); - expect(timefilter.isAutoRefreshSelectorEnabled()).to.be(false); - }); - - it('disables timepicker and auto refresh', () => { - opts = { - ...opts, - options: { - enableTimeFilter: false, - enableAutoRefresh: false, - }, - }; - ctrl = new MonitoringViewBaseController(opts); - - expect(timefilter.isTimeRangeSelectorEnabled()).to.be(false); - expect(timefilter.isAutoRefreshSelectorEnabled()).to.be(false); - }); - - it('disables timepicker and auto refresh', done => { - opts = { - title: 'test', - getPageData: () => httpCall(60), - $injector, - $scope, - }; - - ctrl = new MonitoringViewBaseController({ ...opts }); - ctrl.updateDataPromise = new PromiseWithCancel(httpCall(50)); - - let shouldBeFalse = false; - ctrl.updateDataPromise.promise().then(() => (shouldBeFalse = true)); - - const lastUpdateDataPromise = ctrl.updateDataPromise; - - ctrl.updateData().then(() => { - expect(shouldBeFalse).to.be(false); - expect(lastUpdateDataPromise.status()).to.be(Status.Canceled); - done(); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/access_denied/index.js b/x-pack/legacy/plugins/monitoring/public/views/access_denied/index.js deleted file mode 100644 index a0cfc79f001ca..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/access_denied/index.js +++ /dev/null @@ -1,50 +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 { noop } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import uiChrome from 'plugins/monitoring/np_imports/ui/chrome'; -import template from './index.html'; - -const tryPrivilege = ($http, kbnUrl) => { - return $http - .get('../api/monitoring/v1/check_access') - .then(() => kbnUrl.redirect('/home')) - .catch(noop); -}; - -uiRoutes.when('/access-denied', { - template, - resolve: { - /* - * The user may have been granted privileges in between leaving Monitoring - * and before coming back to Monitoring. That means, they just be on this - * page because Kibana remembers the "last app URL". We check for the - * privilege one time up front (doing it in the resolve makes it happen - * before the template renders), and then keep retrying every 5 seconds. - */ - initialCheck($http, kbnUrl) { - return tryPrivilege($http, kbnUrl); - }, - }, - controllerAs: 'accessDenied', - controller($scope, $injector) { - const $window = $injector.get('$window'); - const kbnBaseUrl = $injector.get('kbnBaseUrl'); - const $http = $injector.get('$http'); - const kbnUrl = $injector.get('kbnUrl'); - const $interval = $injector.get('$interval'); - - // The template's "Back to Kibana" button click handler - this.goToKibana = () => { - $window.location.href = uiChrome.getBasePath() + kbnBaseUrl; - }; - - // keep trying to load data in the background - const accessPoller = $interval(() => tryPrivilege($http, kbnUrl), 5 * 1000); // every 5 seconds - $scope.$on('$destroy', () => $interval.cancel(accessPoller)); - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js deleted file mode 100644 index 62cc985887e9f..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js +++ /dev/null @@ -1,131 +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 { render } from 'react-dom'; -import { find, get } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import template from './index.html'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { I18nContext } from 'ui/i18n'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { Alerts } from '../../components/alerts'; -import { MonitoringViewBaseEuiTableController } from '../base_eui_table_controller'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiLink } from '@elastic/eui'; -import { CODE_PATH_ALERTS, KIBANA_ALERTING_ENABLED } from '../../../common/constants'; - -function getPageData($injector) { - const globalState = $injector.get('globalState'); - const $http = $injector.get('$http'); - const Private = $injector.get('Private'); - const url = KIBANA_ALERTING_ENABLED - ? `../api/monitoring/v1/alert_status` - : `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/legacy_alerts`; - - const timeBounds = timefilter.getBounds(); - const data = { - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }; - - if (!KIBANA_ALERTING_ENABLED) { - data.ccs = globalState.ccs; - } - - return $http - .post(url, data) - .then(response => { - const result = get(response, 'data', []); - if (KIBANA_ALERTING_ENABLED) { - return result.alerts; - } - return result; - }) - .catch(err => { - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/alerts', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ALERTS] }); - }, - alerts: getPageData, - }, - controllerAs: 'alerts', - controller: class AlertsView extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const kbnUrl = $injector.get('kbnUrl'); - - // breadcrumbs + page title - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: i18n.translate('xpack.monitoring.alerts.clusterAlertsTitle', { - defaultMessage: 'Cluster Alerts', - }), - getPageData, - $scope, - $injector, - storageKey: 'alertsTable', - reactNodeId: 'monitoringAlertsApp', - }); - - this.data = $route.current.locals.alerts; - - const renderReact = data => { - const app = data.message ? ( - <p>{data.message}</p> - ) : ( - <Alerts - alerts={data} - angular={{ kbnUrl, scope: $scope }} - sorting={this.sorting} - pagination={this.pagination} - onTableChange={this.onTableChange} - /> - ); - - render( - <I18nContext> - <EuiPage> - <EuiPageBody> - <EuiPageContent> - {app} - <EuiSpacer size="m" /> - <EuiLink href="#/overview"> - <FormattedMessage - id="xpack.monitoring.alerts.clusterOverviewLinkLabel" - defaultMessage="« Cluster Overview" - /> - </EuiLink> - </EuiPageContent> - </EuiPageBody> - </EuiPage> - </I18nContext>, - document.getElementById('monitoringAlertsApp') - ); - }; - $scope.$watch( - () => this.data, - data => renderReact(data) - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/all.js b/x-pack/legacy/plugins/monitoring/public/views/all.js deleted file mode 100644 index ded378b050c2d..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/all.js +++ /dev/null @@ -1,39 +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 './loading'; -import './no_data'; -import './access_denied'; -import './alerts'; -import './license'; -import './cluster/listing'; -import './cluster/overview'; -import './elasticsearch/overview'; -import './elasticsearch/indices'; -import './elasticsearch/index'; -import './elasticsearch/index/advanced'; -import './elasticsearch/nodes'; -import './elasticsearch/node'; -import './elasticsearch/node/advanced'; -import './elasticsearch/ccr'; -import './elasticsearch/ccr/shard'; -import './elasticsearch/ml_jobs'; -import './kibana/overview'; -import './kibana/instances'; -import './kibana/instance'; -import './logstash/overview'; -import './logstash/nodes'; -import './logstash/node'; -import './logstash/node/advanced'; -import './logstash/node/pipelines'; -import './logstash/pipelines'; -import './logstash/pipeline'; -import './beats/overview'; -import './beats/listing'; -import './beats/beat'; -import './apm/overview'; -import './apm/instances'; -import './apm/instance'; diff --git a/x-pack/legacy/plugins/monitoring/public/views/apm/instance/index.js b/x-pack/legacy/plugins/monitoring/public/views/apm/instance/index.js deleted file mode 100644 index 4d0f858d28117..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/apm/instance/index.js +++ /dev/null @@ -1,79 +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. - */ - -/* - * 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 { find, get } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { ApmServerInstance } from '../../../components/apm/instance'; -import { I18nContext } from 'ui/i18n'; -import { CODE_PATH_APM } from '../../../../common/constants'; - -uiRoutes.when('/apm/instances/:uuid', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_APM] }); - }, - }, - - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const title = $injector.get('title'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: i18n.translate('xpack.monitoring.apm.instance.routeTitle', { - defaultMessage: '{apm} - Instance', - values: { - apm: 'APM', - }, - }), - api: `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/apm/${$route.current.params.uuid}`, - defaultData: {}, - reactNodeId: 'apmInstanceReact', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - title($scope.cluster, `APM - ${get(data, 'apmSummary.name')}`); - this.renderReact(data); - } - ); - } - - renderReact(data) { - const component = ( - <I18nContext> - <ApmServerInstance - summary={data.apmSummary || {}} - metrics={data.metrics || {}} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - </I18nContext> - ); - super.renderReact(component); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/apm/instances/index.js b/x-pack/legacy/plugins/monitoring/public/views/apm/instances/index.js deleted file mode 100644 index 317879063b6e5..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/apm/instances/index.js +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment } from 'react'; -import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { ApmServerInstances } from '../../../components/apm/instances'; -import { MonitoringViewBaseEuiTableController } from '../..'; -import { I18nContext } from 'ui/i18n'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { APM_SYSTEM_ID, CODE_PATH_APM } from '../../../../common/constants'; - -uiRoutes.when('/apm/instances', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_APM] }); - }, - }, - controller: class extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: i18n.translate('xpack.monitoring.apm.instances.routeTitle', { - defaultMessage: '{apm} - Instances', - values: { - apm: 'APM', - }, - }), - storageKey: 'apm.instances', - api: `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/apm/instances`, - defaultData: {}, - reactNodeId: 'apmInstancesReact', - $scope, - $injector, - }); - - this.scope = $scope; - this.injector = $injector; - - $scope.$watch( - () => this.data, - data => { - this.renderReact(data); - } - ); - } - - renderReact(data) { - const { pagination, sorting, onTableChange } = this; - - const component = ( - <I18nContext> - <SetupModeRenderer - scope={this.scope} - injector={this.injector} - productName={APM_SYSTEM_ID} - render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( - <Fragment> - {flyoutComponent} - <ApmServerInstances - setupMode={setupMode} - apms={{ - pagination, - sorting, - onTableChange, - data, - }} - /> - {bottomBarComponent} - </Fragment> - )} - /> - </I18nContext> - ); - super.renderReact(component); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/apm/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/apm/overview/index.js deleted file mode 100644 index e6562f428d2a0..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/apm/overview/index.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { find } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { ApmOverview } from '../../../components/apm/overview'; -import { I18nContext } from 'ui/i18n'; -import { CODE_PATH_APM } from '../../../../common/constants'; - -uiRoutes.when('/apm', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_APM] }); - }, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: 'APM', - api: `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/apm`, - defaultData: {}, - reactNodeId: 'apmOverviewReact', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - this.renderReact(data); - } - ); - } - - renderReact(data) { - const component = ( - <I18nContext> - <ApmOverview {...data} onBrush={this.onBrush} zoomInfo={this.zoomInfo} /> - </I18nContext> - ); - super.renderReact(component); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/base_controller.js b/x-pack/legacy/plugins/monitoring/public/views/base_controller.js deleted file mode 100644 index 25b4d97177a98..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/base_controller.js +++ /dev/null @@ -1,211 +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 moment from 'moment'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { getPageData } from '../lib/get_page_data'; -import { PageLoading } from 'plugins/monitoring/components'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { I18nContext } from 'ui/i18n'; -import { PromiseWithCancel } from '../../common/cancel_promise'; -import { updateSetupModeData, getSetupModeState } from '../lib/setup_mode'; - -/** - * Given a timezone, this function will calculate the offset in milliseconds - * from UTC time. - * - * @param {string} timezone - */ -const getOffsetInMS = timezone => { - if (timezone === 'Browser') { - return 0; - } - const offsetInMinutes = moment.tz(timezone).utcOffset(); - const offsetInMS = offsetInMinutes * 1 * 60 * 1000; - return offsetInMS; -}; - -/** - * Class to manage common instantiation behaviors in a view controller - * - * This is expected to be extended, and behavior enabled using super(); - * - * Example: - * uiRoutes.when('/myRoute', { - * template: importedTemplate, - * controllerAs: 'myView', - * controller: class MyView extends MonitoringViewBaseController { - * constructor($injector, $scope) { - * super({ - * title: 'Hello World', - * api: '../api/v1/monitoring/foo/bar', - * defaultData, - * reactNodeId, - * $scope, - * $injector, - * options: { - * enableTimeFilter: false // this will have just the page auto-refresh control show - * } - * }); - * } - * } - * }); - */ -export class MonitoringViewBaseController { - /** - * Create a view controller - * @param {String} title - Title of the page - * @param {String} api - Back-end API endpoint to poll for getting the page - * data using POST and time range data in the body. Whenever possible, use - * this method for data polling rather than supply the getPageData param. - * @param {Function} apiUrlFn - Function that returns a string for the back-end - * API endpoint, in case the string has dynamic query parameters (e.g. - * show_system_indices) rather than supply the getPageData param. - * @param {Function} getPageData - (Optional) Function to fetch page data, if - * simply passing the API string isn't workable. - * @param {Object} defaultData - Initial model data to populate - * @param {String} reactNodeId - DOM element ID of the element for mounting - * the view's main React component - * @param {Service} $injector - Angular dependency injection service - * @param {Service} $scope - Angular view data binding service - * @param {Boolean} options.enableTimeFilter - Whether to show the time filter - * @param {Boolean} options.enableAutoRefresh - Whether to show the auto - * refresh control - */ - constructor({ - title = '', - api = '', - apiUrlFn, - getPageData: _getPageData = getPageData, - defaultData, - reactNodeId = null, // WIP: https://github.com/elastic/x-pack-kibana/issues/5198 - $scope, - $injector, - options = {}, - fetchDataImmediately = true, - }) { - const titleService = $injector.get('title'); - const $executor = $injector.get('$executor'); - const $window = $injector.get('$window'); - const config = $injector.get('config'); - - titleService($scope.cluster, title); - - $scope.pageData = this.data = { ...defaultData }; - this._isDataInitialized = false; - this.reactNodeId = reactNodeId; - - let deferTimer; - let zoomInLevel = 0; - - const popstateHandler = () => zoomInLevel > 0 && --zoomInLevel; - const removePopstateHandler = () => $window.removeEventListener('popstate', popstateHandler); - const addPopstateHandler = () => $window.addEventListener('popstate', popstateHandler); - - this.zoomInfo = { - zoomOutHandler: () => $window.history.back(), - showZoomOutBtn: () => zoomInLevel > 0, - }; - - const { enableTimeFilter = true, enableAutoRefresh = true } = options; - - if (enableTimeFilter === false) { - timefilter.disableTimeRangeSelector(); - } else { - timefilter.enableTimeRangeSelector(); - } - - if (enableAutoRefresh === false) { - timefilter.disableAutoRefreshSelector(); - } else { - timefilter.enableAutoRefreshSelector(); - } - - this.updateData = () => { - if (this.updateDataPromise) { - // Do not sent another request if one is inflight - // See https://github.com/elastic/kibana/issues/24082 - this.updateDataPromise.cancel(); - this.updateDataPromise = null; - } - const _api = apiUrlFn ? apiUrlFn() : api; - const promises = [_getPageData($injector, _api, this.getPaginationRouteOptions())]; - const setupMode = getSetupModeState(); - if (setupMode.enabled) { - promises.push(updateSetupModeData()); - } - this.updateDataPromise = new PromiseWithCancel(Promise.all(promises)); - return this.updateDataPromise.promise().then(([pageData]) => { - $scope.$apply(() => { - this._isDataInitialized = true; // render will replace loading screen with the react component - $scope.pageData = this.data = pageData; // update the view's data with the fetch result - }); - }); - }; - fetchDataImmediately && this.updateData(); - - $executor.register({ - execute: () => this.updateData(), - }); - $executor.start($scope); - $scope.$on('$destroy', () => { - clearTimeout(deferTimer); - removePopstateHandler(); - if (this.reactNodeId) { - // WIP https://github.com/elastic/x-pack-kibana/issues/5198 - unmountComponentAtNode(document.getElementById(this.reactNodeId)); - } - $executor.destroy(); - }); - - // needed for chart pages - this.onBrush = ({ xaxis }) => { - removePopstateHandler(); - const { to, from } = xaxis; - const timezone = config.get('dateFormat:tz'); - const offset = getOffsetInMS(timezone); - timefilter.setTime({ - from: moment(from - offset), - to: moment(to - offset), - mode: 'absolute', - }); - $executor.cancel(); - $executor.run(); - ++zoomInLevel; - clearTimeout(deferTimer); - /* - Needed to defer 'popstate' event, so it does not fire immediately after it's added. - 10ms is to make sure the event is not added with the same code digest - */ - deferTimer = setTimeout(() => addPopstateHandler(), 10); - }; - - this.setTitle = title => titleService($scope.cluster, title); - } - - renderReact(component) { - const renderElement = document.getElementById(this.reactNodeId); - if (!renderElement) { - console.warn(`"#${this.reactNodeId}" element has not been added to the DOM yet`); - return; - } - if (this._isDataInitialized === false) { - render( - <I18nContext> - <PageLoading /> - </I18nContext>, - renderElement - ); - } else { - render(component, renderElement); - } - } - - getPaginationRouteOptions() { - return {}; - } -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/beats/beat/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/views/beats/beat/get_page_data.js deleted file mode 100644 index 7e77e93d52fe8..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/beats/beat/get_page_data.js +++ /dev/null @@ -1,31 +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 { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/beats/beat/${$route.current.params.beatUuid}`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/beats/beat/index.js b/x-pack/legacy/plugins/monitoring/public/views/beats/beat/index.js deleted file mode 100644 index b3fad1b4cc3cb..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/beats/beat/index.js +++ /dev/null @@ -1,51 +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 { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { MonitoringViewBaseController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { CODE_PATH_BEATS } from '../../../../common/constants'; - -uiRoutes.when('/beats/beat/:beatUuid', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_BEATS] }); - }, - pageData: getPageData, - }, - controllerAs: 'beat', - controller: class BeatDetail extends MonitoringViewBaseController { - constructor($injector, $scope) { - // breadcrumbs + page title - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - const pageData = $route.current.locals.pageData; - super({ - title: i18n.translate('xpack.monitoring.beats.instance.routeTitle', { - defaultMessage: 'Beats - {instanceName} - Overview', - values: { - instanceName: pageData.summary.name, - }, - }), - getPageData, - $scope, - $injector, - }); - - this.data = pageData; - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/beats/listing/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/views/beats/listing/get_page_data.js deleted file mode 100644 index 1838011dee652..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/beats/listing/get_page_data.js +++ /dev/null @@ -1,30 +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 { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const timeBounds = timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/beats/beats`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/beats/listing/index.js b/x-pack/legacy/plugins/monitoring/public/views/beats/listing/index.js deleted file mode 100644 index 48848007c9c27..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/beats/listing/index.js +++ /dev/null @@ -1,93 +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 { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import React, { Fragment } from 'react'; -import { I18nContext } from 'ui/i18n'; -import { Listing } from '../../../components/beats/listing/listing'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { CODE_PATH_BEATS, BEATS_SYSTEM_ID } from '../../../../common/constants'; - -uiRoutes.when('/beats/beats', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_BEATS] }); - }, - pageData: getPageData, - }, - controllerAs: 'beats', - controller: class BeatsListing extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - // breadcrumbs + page title - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: i18n.translate('xpack.monitoring.beats.routeTitle', { defaultMessage: 'Beats' }), - storageKey: 'beats.beats', - getPageData, - reactNodeId: 'monitoringBeatsInstancesApp', - $scope, - $injector, - }); - - this.data = $route.current.locals.pageData; - this.scope = $scope; - this.injector = $injector; - this.kbnUrl = $injector.get('kbnUrl'); - - //Bypassing super.updateData, since this controller loads its own data - this._isDataInitialized = true; - - $scope.$watch( - () => this.data, - () => this.renderComponent() - ); - } - - renderComponent() { - const { sorting, pagination, onTableChange } = this.scope.beats; - this.renderReact( - <I18nContext> - <SetupModeRenderer - scope={this.scope} - injector={this.injector} - productName={BEATS_SYSTEM_ID} - render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( - <Fragment> - {flyoutComponent} - <Listing - stats={this.data.stats} - data={this.data.listing} - setupMode={setupMode} - sorting={this.sorting || sorting} - pagination={this.pagination || pagination} - onTableChange={this.onTableChange || onTableChange} - angular={{ - kbnUrl: this.kbnUrl, - scope: this.scope, - }} - /> - {bottomBarComponent} - </Fragment> - )} - /> - </I18nContext> - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/beats/overview/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/views/beats/overview/get_page_data.js deleted file mode 100644 index a3b120b277b94..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/beats/overview/get_page_data.js +++ /dev/null @@ -1,30 +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 { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const timeBounds = timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/beats`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/beats/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/beats/overview/index.js deleted file mode 100644 index aea62d5c7f78f..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/beats/overview/index.js +++ /dev/null @@ -1,47 +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 { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { MonitoringViewBaseController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { CODE_PATH_BEATS } from '../../../../common/constants'; - -uiRoutes.when('/beats', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_BEATS] }); - }, - pageData: getPageData, - }, - controllerAs: 'beats', - controller: class BeatsOverview extends MonitoringViewBaseController { - constructor($injector, $scope) { - // breadcrumbs + page title - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: i18n.translate('xpack.monitoring.beats.overview.routeTitle', { - defaultMessage: 'Beats - Overview', - }), - getPageData, - $scope, - $injector, - }); - - this.data = $route.current.locals.pageData; - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/cluster/listing/index.js b/x-pack/legacy/plugins/monitoring/public/views/cluster/listing/index.js deleted file mode 100644 index 958226514b146..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/cluster/listing/index.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { I18nContext } from 'ui/i18n'; -import template from './index.html'; -import { Listing } from '../../../components/cluster/listing'; -import { CODE_PATH_ALL } from '../../../../common/constants'; - -const CODE_PATHS = [CODE_PATH_ALL]; - -const getPageData = $injector => { - const monitoringClusters = $injector.get('monitoringClusters'); - return monitoringClusters(undefined, undefined, CODE_PATHS); -}; - -uiRoutes - .when('/home', { - template, - resolve: { - clusters: (Private, kbnUrl) => { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: CODE_PATHS, fetchAllClusters: true }).then(clusters => { - if (!clusters || !clusters.length) { - kbnUrl.changePath('/no-data'); - return Promise.reject(); - } - if (clusters.length === 1) { - // Bypass the cluster listing if there is just 1 cluster - kbnUrl.changePath('/overview'); - return Promise.reject(); - } - return clusters; - }); - }, - }, - controllerAs: 'clusters', - controller: class ClustersList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - super({ - storageKey: 'clusters', - getPageData, - $scope, - $injector, - reactNodeId: 'monitoringClusterListingApp', - }); - - const $route = $injector.get('$route'); - const kbnUrl = $injector.get('kbnUrl'); - const globalState = $injector.get('globalState'); - const storage = $injector.get('localStorage'); - const showLicenseExpiration = $injector.get('showLicenseExpiration'); - - this.data = $route.current.locals.clusters; - - $scope.$watch( - () => this.data, - data => { - this.renderReact( - <I18nContext> - <Listing - clusters={data} - angular={{ - scope: $scope, - globalState, - kbnUrl, - storage, - showLicenseExpiration, - }} - sorting={this.sorting} - pagination={this.pagination} - onTableChange={this.onTableChange} - /> - </I18nContext> - ); - } - ); - } - }, - }) - .otherwise({ redirectTo: '/no-data' }); diff --git a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js deleted file mode 100644 index e1777b8ed7b49..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js +++ /dev/null @@ -1,103 +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, { Fragment } from 'react'; -import { isEmpty } from 'lodash'; -import chrome from 'ui/chrome'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { MonitoringViewBaseController } from '../../'; -import { Overview } from 'plugins/monitoring/components/cluster/overview'; -import { I18nContext } from 'ui/i18n'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { - CODE_PATH_ALL, - MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS, - KIBANA_ALERTING_ENABLED, -} from '../../../../common/constants'; - -const CODE_PATHS = [CODE_PATH_ALL]; - -uiRoutes.when('/overview', { - template, - resolve: { - clusters(Private) { - // checks license info of all monitored clusters for multi-cluster monitoring usage and capability - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: CODE_PATHS }); - }, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const kbnUrl = $injector.get('kbnUrl'); - const monitoringClusters = $injector.get('monitoringClusters'); - const globalState = $injector.get('globalState'); - const showLicenseExpiration = $injector.get('showLicenseExpiration'); - const config = $injector.get('config'); - - super({ - title: i18n.translate('xpack.monitoring.cluster.overviewTitle', { - defaultMessage: 'Overview', - }), - defaultData: {}, - getPageData: async () => { - const clusters = await monitoringClusters( - globalState.cluster_uuid, - globalState.ccs, - CODE_PATHS - ); - return clusters[0]; - }, - reactNodeId: 'monitoringClusterOverviewApp', - $scope, - $injector, - }); - - const changeUrl = target => { - $scope.$evalAsync(() => { - kbnUrl.changePath(target); - }); - }; - - $scope.$watch( - () => this.data, - async data => { - if (isEmpty(data)) { - return; - } - - let emailAddress = chrome.getInjected('monitoringLegacyEmailAddress') || ''; - if (KIBANA_ALERTING_ENABLED) { - emailAddress = config.get(MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS) || emailAddress; - } - - this.renderReact( - <I18nContext> - <SetupModeRenderer - scope={$scope} - injector={$injector} - render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( - <Fragment> - {flyoutComponent} - <Overview - cluster={data} - emailAddress={emailAddress} - setupMode={setupMode} - changeUrl={changeUrl} - showLicenseExpiration={showLicenseExpiration} - /> - {bottomBarComponent} - </Fragment> - )} - /> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/get_page_data.js deleted file mode 100644 index 83dd24209dfe3..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/get_page_data.js +++ /dev/null @@ -1,30 +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 { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const timeBounds = timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/ccr`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/index.js deleted file mode 100644 index cf51347842f4a..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/index.js +++ /dev/null @@ -1,56 +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 uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { getPageData } from './get_page_data'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { Ccr } from '../../../components/elasticsearch/ccr'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { I18nContext } from 'ui/i18n'; -import { CODE_PATH_ELASTICSEARCH } from '../../../../common/constants'; - -uiRoutes.when('/elasticsearch/ccr', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'elasticsearchCcr', - controller: class ElasticsearchCcrController extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.ccr.routeTitle', { - defaultMessage: 'Elasticsearch - Ccr', - }), - reactNodeId: 'elasticsearchCcrReact', - getPageData, - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - this.renderReact(data); - } - ); - - this.renderReact = ({ data }) => { - super.renderReact( - <I18nContext> - <Ccr data={data} /> - </I18nContext> - ); - }; - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/shard/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/shard/get_page_data.js deleted file mode 100644 index 22ca094d28b07..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/shard/get_page_data.js +++ /dev/null @@ -1,31 +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 { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const timeBounds = timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/ccr/${$route.current.params.index}/shard/${$route.current.params.shardId}`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js deleted file mode 100644 index ff35f7f743f66..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js +++ /dev/null @@ -1,65 +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 { get } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { getPageData } from './get_page_data'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { MonitoringViewBaseController } from '../../../base_controller'; -import { CcrShard } from '../../../../components/elasticsearch/ccr_shard'; -import { I18nContext } from 'ui/i18n'; -import { CODE_PATH_ELASTICSEARCH } from '../../../../../common/constants'; - -uiRoutes.when('/elasticsearch/ccr/:index/shard/:shardId', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'elasticsearchCcr', - controller: class ElasticsearchCcrController extends MonitoringViewBaseController { - constructor($injector, $scope, pageData) { - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.ccr.shard.routeTitle', { - defaultMessage: 'Elasticsearch - Ccr - Shard', - }), - reactNodeId: 'elasticsearchCcrShardReact', - getPageData, - $scope, - $injector, - }); - - $scope.instance = i18n.translate('xpack.monitoring.elasticsearch.ccr.shard.instanceTitle', { - defaultMessage: 'Index: {followerIndex} Shard: {shardId}', - values: { - followerIndex: get(pageData, 'stat.follower_index'), - shardId: get(pageData, 'stat.shard_id'), - }, - }); - - $scope.$watch( - () => this.data, - data => { - this.renderReact(data); - } - ); - - this.renderReact = props => { - super.renderReact( - <I18nContext> - <CcrShard {...props} /> - </I18nContext> - ); - }; - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/index/advanced/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/index/advanced/index.js deleted file mode 100644 index 4fc439b4e0123..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/index/advanced/index.js +++ /dev/null @@ -1,94 +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. - */ - -/** - * Controller for Advanced Index Detail - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { AdvancedIndex } from '../../../../components/elasticsearch/index/advanced'; -import { I18nContext } from 'ui/i18n'; -import { MonitoringViewBaseController } from '../../../base_controller'; -import { CODE_PATH_ELASTICSEARCH } from '../../../../../common/constants'; - -function getPageData($injector) { - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/indices/${$route.current.params.index}`; - const $http = $injector.get('$http'); - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: true, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/elasticsearch/indices/:index/advanced', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringElasticsearchAdvancedIndexApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const indexName = $route.current.params.index; - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.indices.advanced.routeTitle', { - defaultMessage: 'Elasticsearch - Indices - {indexName} - Advanced', - values: { - indexName, - }, - }), - defaultData: {}, - getPageData, - reactNodeId: 'monitoringElasticsearchAdvancedIndexApp', - $scope, - $injector, - }); - - this.indexName = indexName; - - $scope.$watch( - () => this.data, - data => { - this.renderReact( - <I18nContext> - <AdvancedIndex - indexSummary={data.indexSummary} - metrics={data.metrics} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/index/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/index/index.js deleted file mode 100644 index bbeef8294a897..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/index/index.js +++ /dev/null @@ -1,115 +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. - */ - -/** - * Controller for single index detail - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { I18nContext } from 'ui/i18n'; -import { labels } from '../../../components/elasticsearch/shard_allocation/lib/labels'; -import { indicesByNodes } from '../../../components/elasticsearch/shard_allocation/transformers/indices_by_nodes'; -import { Index } from '../../../components/elasticsearch/index/index'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_ELASTICSEARCH } from '../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/indices/${$route.current.params.index}`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: false, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/elasticsearch/indices/:index', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringElasticsearchIndexApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const kbnUrl = $injector.get('kbnUrl'); - const indexName = $route.current.params.index; - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.indices.overview.routeTitle', { - defaultMessage: 'Elasticsearch - Indices - {indexName} - Overview', - values: { - indexName, - }, - }), - defaultData: {}, - getPageData, - reactNodeId: 'monitoringElasticsearchIndexApp', - $scope, - $injector, - }); - - this.indexName = indexName; - const transformer = indicesByNodes(); - - $scope.$watch( - () => this.data, - data => { - if (!data || !data.shards) { - return; - } - - const shards = data.shards; - $scope.totalCount = shards.length; - $scope.showing = transformer(shards, data.nodes); - $scope.labels = labels.node; - if (shards.some(shard => shard.state === 'UNASSIGNED')) { - $scope.labels = labels.indexWithUnassigned; - } else { - $scope.labels = labels.index; - } - - this.renderReact( - <I18nContext> - <Index - scope={$scope} - kbnUrl={kbnUrl} - onBrush={this.onBrush} - indexUuid={this.indexName} - clusterUuid={$scope.cluster.cluster_uuid} - zoomInfo={this.zoomInfo} - {...data} - /> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/indices/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/indices/index.js deleted file mode 100644 index f1d96557b0c1c..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/indices/index.js +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { ElasticsearchIndices } from '../../../components'; -import template from './index.html'; -import { I18nContext } from 'ui/i18n'; -import { CODE_PATH_ELASTICSEARCH } from '../../../../common/constants'; - -uiRoutes.when('/elasticsearch/indices', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - }, - controllerAs: 'elasticsearchIndices', - controller: class ElasticsearchIndicesController extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const features = $injector.get('features'); - - const { cluster_uuid: clusterUuid } = globalState; - $scope.cluster = find($route.current.locals.clusters, { cluster_uuid: clusterUuid }); - - let showSystemIndices = features.isEnabled('showSystemIndices', false); - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.indices.routeTitle', { - defaultMessage: 'Elasticsearch - Indices', - }), - storageKey: 'elasticsearch.indices', - apiUrlFn: () => - `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices?show_system_indices=${showSystemIndices}`, - reactNodeId: 'elasticsearchIndicesReact', - defaultData: {}, - $scope, - $injector, - $scope, - $injector, - }); - - this.isCcrEnabled = $scope.cluster.isCcrEnabled; - - // for binding - const toggleShowSystemIndices = isChecked => { - // flip the boolean - showSystemIndices = isChecked; - // preserve setting in localStorage - features.update('showSystemIndices', isChecked); - // update the page (resets pagination and sorting) - this.updateData(); - }; - - $scope.$watch( - () => this.data, - data => { - this.renderReact(data); - } - ); - - this.renderReact = ({ clusterStatus, indices }) => { - super.renderReact( - <I18nContext> - <ElasticsearchIndices - clusterStatus={clusterStatus} - indices={indices} - showSystemIndices={showSystemIndices} - toggleShowSystemIndices={toggleShowSystemIndices} - sorting={this.sorting} - pagination={this.pagination} - onTableChange={this.onTableChange} - /> - </I18nContext> - ); - }; - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ml_jobs/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ml_jobs/get_page_data.js deleted file mode 100644 index 1943b580f7a75..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ml_jobs/get_page_data.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/ml_jobs`; - const timeBounds = timefilter.getBounds(); - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.js deleted file mode 100644 index 5e66a4147ab70..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.js +++ /dev/null @@ -1,47 +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 { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { CODE_PATH_ELASTICSEARCH, CODE_PATH_ML } from '../../../../common/constants'; - -uiRoutes.when('/elasticsearch/ml_jobs', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH, CODE_PATH_ML] }); - }, - pageData: getPageData, - }, - controllerAs: 'mlJobs', - controller: class MlJobsList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.mlJobs.routeTitle', { - defaultMessage: 'Elasticsearch - Machine Learning Jobs', - }), - storageKey: 'elasticsearch.mlJobs', - getPageData, - $scope, - $injector, - }); - - const $route = $injector.get('$route'); - this.data = $route.current.locals.pageData; - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - this.isCcrEnabled = Boolean($scope.cluster && $scope.cluster.isCcrEnabled); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js deleted file mode 100644 index 2bbdf604d00ce..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js +++ /dev/null @@ -1,95 +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. - */ - -/** - * Controller for Advanced Node Detail - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { I18nContext } from 'ui/i18n'; -import { AdvancedNode } from '../../../../components/elasticsearch/node/advanced'; -import { MonitoringViewBaseController } from '../../../base_controller'; -import { CODE_PATH_ELASTICSEARCH } from '../../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const timeBounds = timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/nodes/${$route.current.params.node}`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: true, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/elasticsearch/nodes/:node/advanced', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - defaultData: {}, - getPageData, - reactNodeId: 'monitoringElasticsearchAdvancedNodeApp', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - if (!data || !data.nodeSummary) { - return; - } - - this.setTitle( - i18n.translate('xpack.monitoring.elasticsearch.node.advanced.routeTitle', { - defaultMessage: 'Elasticsearch - Nodes - {nodeSummaryName} - Advanced', - values: { - nodeSummaryName: data.nodeSummary.name, - }, - }) - ); - - this.renderReact( - <I18nContext> - <AdvancedNode - nodeSummary={data.nodeSummary} - metrics={data.metrics} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/node/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/node/get_page_data.js deleted file mode 100644 index 0d9e0b25eacd0..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/node/get_page_data.js +++ /dev/null @@ -1,35 +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 { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/nodes/${$route.current.params.node}`; - const features = $injector.get('features'); - const showSystemIndices = features.isEnabled('showSystemIndices', false); - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - showSystemIndices, - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: false, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/node/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/node/index.js deleted file mode 100644 index fa76222d78e2d..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/node/index.js +++ /dev/null @@ -1,98 +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. - */ - -/** - * Controller for Node Detail - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { partial } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { Node } from '../../../components/elasticsearch/node/node'; -import { I18nContext } from 'ui/i18n'; -import { labels } from '../../../components/elasticsearch/shard_allocation/lib/labels'; -import { nodesByIndices } from '../../../components/elasticsearch/shard_allocation/transformers/nodes_by_indices'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_ELASTICSEARCH } from '../../../../common/constants'; - -uiRoutes.when('/elasticsearch/nodes/:node', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringElasticsearchNodeApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const kbnUrl = $injector.get('kbnUrl'); - const nodeName = $route.current.params.node; - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.node.overview.routeTitle', { - defaultMessage: 'Elasticsearch - Nodes - {nodeName} - Overview', - values: { - nodeName, - }, - }), - defaultData: {}, - getPageData, - reactNodeId: 'monitoringElasticsearchNodeApp', - $scope, - $injector, - }); - - this.nodeName = nodeName; - - const features = $injector.get('features'); - const callPageData = partial(getPageData, $injector); - // show/hide system indices in shard allocation view - $scope.showSystemIndices = features.isEnabled('showSystemIndices', false); - $scope.toggleShowSystemIndices = isChecked => { - $scope.showSystemIndices = isChecked; - // preserve setting in localStorage - features.update('showSystemIndices', isChecked); - // update the page - callPageData().then(data => (this.data = data)); - }; - - const transformer = nodesByIndices(); - $scope.$watch( - () => this.data, - data => { - if (!data || !data.shards) { - return; - } - - const shards = data.shards; - $scope.totalCount = shards.length; - $scope.showing = transformer(shards, data.nodes); - $scope.labels = labels.node; - - this.renderReact( - <I18nContext> - <Node - scope={$scope} - kbnUrl={kbnUrl} - nodeId={this.nodeName} - clusterUuid={$scope.cluster.cluster_uuid} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - {...data} - /> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/nodes/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/nodes/index.js deleted file mode 100644 index a9a6774d4c883..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/nodes/index.js +++ /dev/null @@ -1,119 +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, { Fragment } from 'react'; -import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import template from './index.html'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { ElasticsearchNodes } from '../../../components'; -import { I18nContext } from 'ui/i18n'; -import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { ELASTICSEARCH_SYSTEM_ID, CODE_PATH_ELASTICSEARCH } from '../../../../common/constants'; - -uiRoutes.when('/elasticsearch/nodes', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - }, - controllerAs: 'elasticsearchNodes', - controller: class ElasticsearchNodesController extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const showCgroupMetricsElasticsearch = $injector.get('showCgroupMetricsElasticsearch'); - - $scope.cluster = - find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }) || {}; - - const getPageData = ($injector, _api = undefined, routeOptions = {}) => { - _api; // to fix eslint - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const timeBounds = timefilter.getBounds(); - - const getNodes = (clusterUuid = globalState.cluster_uuid) => - $http.post(`../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/nodes`, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - ...routeOptions, - }); - - const promise = globalState.cluster_uuid ? getNodes() : new Promise(resolve => resolve({})); - return promise - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); - }; - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.nodes.routeTitle', { - defaultMessage: 'Elasticsearch - Nodes', - }), - storageKey: 'elasticsearch.nodes', - reactNodeId: 'elasticsearchNodesReact', - defaultData: {}, - getPageData, - $scope, - $injector, - fetchDataImmediately: false, // We want to apply pagination before sending the first request - }); - - this.isCcrEnabled = $scope.cluster.isCcrEnabled; - - $scope.$watch( - () => this.data, - () => this.renderReact(this.data || {}) - ); - - this.renderReact = ({ clusterStatus, nodes, totalNodeCount }) => { - const pagination = { - ...this.pagination, - totalItemCount: totalNodeCount, - }; - - super.renderReact( - <I18nContext> - <SetupModeRenderer - scope={$scope} - injector={$injector} - productName={ELASTICSEARCH_SYSTEM_ID} - render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( - <Fragment> - {flyoutComponent} - <ElasticsearchNodes - clusterStatus={clusterStatus} - clusterUuid={globalState.cluster_uuid} - setupMode={setupMode} - nodes={nodes} - showCgroupMetricsElasticsearch={showCgroupMetricsElasticsearch} - {...this.getPaginationTableProps(pagination)} - /> - {bottomBarComponent} - </Fragment> - )} - /> - </I18nContext> - ); - }; - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/overview/controller.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/overview/controller.js deleted file mode 100644 index 9f59b4d632222..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/overview/controller.js +++ /dev/null @@ -1,98 +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 { find } from 'lodash'; -import { MonitoringViewBaseController } from '../../'; -import { ElasticsearchOverview } from 'plugins/monitoring/components'; -import { I18nContext } from 'ui/i18n'; - -export class ElasticsearchOverviewController extends MonitoringViewBaseController { - constructor($injector, $scope) { - // breadcrumbs + page title - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: 'Elasticsearch', - api: `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch`, - defaultData: { - clusterStatus: { status: '' }, - metrics: null, - shardActivity: null, - }, - reactNodeId: 'elasticsearchOverviewReact', - $scope, - $injector, - }); - - this.isCcrEnabled = $scope.cluster.isCcrEnabled; - this.showShardActivityHistory = false; - this.toggleShardActivityHistory = () => { - this.showShardActivityHistory = !this.showShardActivityHistory; - $scope.$evalAsync(() => { - this.renderReact(this.data, $scope.cluster); - }); - }; - - this.initScope($scope); - } - - initScope($scope) { - $scope.$watch( - () => this.data, - data => { - this.renderReact(data, $scope.cluster); - } - ); - - // HACK to force table to re-render even if data hasn't changed. This - // happens when the data remains empty after turning on showHistory. The - // button toggle needs to update the "no data" message based on the value of showHistory - $scope.$watch( - () => this.showShardActivityHistory, - () => { - const { data } = this; - const dataWithShardActivityLoading = { ...data, shardActivity: null }; - // force shard activity to rerender by manipulating and then re-setting its data prop - this.renderReact(dataWithShardActivityLoading, $scope.cluster); - this.renderReact(data, $scope.cluster); - } - ); - } - - filterShardActivityData(shardActivity) { - return shardActivity.filter(row => { - return this.showShardActivityHistory || row.stage !== 'DONE'; - }); - } - - renderReact(data, cluster) { - // All data needs to originate in this view, and get passed as a prop to the components, for statelessness - const { clusterStatus, metrics, shardActivity, logs } = data; - const shardActivityData = shardActivity && this.filterShardActivityData(shardActivity); // no filter on data = null - const component = ( - <I18nContext> - <ElasticsearchOverview - clusterStatus={clusterStatus} - metrics={metrics} - logs={logs} - cluster={cluster} - shardActivity={shardActivityData} - onBrush={this.onBrush} - showShardActivityHistory={this.showShardActivityHistory} - toggleShardActivityHistory={this.toggleShardActivityHistory} - zoomInfo={this.zoomInfo} - /> - </I18nContext> - ); - - super.renderReact(component); - } -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/overview/index.js deleted file mode 100644 index 475c0fc494857..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/elasticsearch/overview/index.js +++ /dev/null @@ -1,23 +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 uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { ElasticsearchOverviewController } from './controller'; -import { CODE_PATH_ELASTICSEARCH } from '../../../../common/constants'; - -uiRoutes.when('/elasticsearch', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - }, - controllerAs: 'elasticsearchOverview', - controller: ElasticsearchOverviewController, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/kibana/instance/index.js b/x-pack/legacy/plugins/monitoring/public/views/kibana/instance/index.js deleted file mode 100644 index 6535bd7410445..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/kibana/instance/index.js +++ /dev/null @@ -1,153 +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. - */ - -/* - * Kibana Instance - */ -import React from 'react'; -import { get } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiSpacer, - EuiFlexGrid, - EuiFlexItem, - EuiPanel, -} from '@elastic/eui'; -import { MonitoringTimeseriesContainer } from '../../../components/chart'; -import { DetailStatus } from 'plugins/monitoring/components/kibana/detail_status'; -import { I18nContext } from 'ui/i18n'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_KIBANA } from '../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/kibana/${$route.current.params.uuid}`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/kibana/instances/:uuid', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_KIBANA] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringKibanaInstanceApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - title: `Kibana - ${get($scope.pageData, 'kibanaSummary.name')}`, - defaultData: {}, - getPageData, - reactNodeId: 'monitoringKibanaInstanceApp', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - if (!data || !data.metrics) { - return; - } - - this.setTitle(`Kibana - ${get(data, 'kibanaSummary.name')}`); - - this.renderReact( - <I18nContext> - <EuiPage> - <EuiPageBody> - <EuiPanel> - <DetailStatus stats={data.kibanaSummary} /> - </EuiPanel> - <EuiSpacer size="m" /> - <EuiPageContent> - <EuiFlexGrid columns={2} gutterSize="s"> - <EuiFlexItem grow={true}> - <MonitoringTimeseriesContainer - series={data.metrics.kibana_requests} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - <EuiSpacer /> - </EuiFlexItem> - <EuiFlexItem grow={true}> - <MonitoringTimeseriesContainer - series={data.metrics.kibana_response_times} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - <EuiSpacer /> - </EuiFlexItem> - <EuiFlexItem grow={true}> - <MonitoringTimeseriesContainer - series={data.metrics.kibana_memory} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - <EuiSpacer /> - </EuiFlexItem> - <EuiFlexItem grow={true}> - <MonitoringTimeseriesContainer - series={data.metrics.kibana_average_concurrent_connections} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - <EuiSpacer /> - </EuiFlexItem> - <EuiFlexItem grow={true}> - <MonitoringTimeseriesContainer - series={data.metrics.kibana_os_load} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - <EuiSpacer /> - </EuiFlexItem> - <EuiFlexItem grow={true}> - <MonitoringTimeseriesContainer - series={data.metrics.kibana_process_delay} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - <EuiSpacer /> - </EuiFlexItem> - </EuiFlexGrid> - </EuiPageContent> - </EuiPageBody> - </EuiPage> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/get_page_data.js deleted file mode 100644 index 4f8d7fa20d332..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/get_page_data.js +++ /dev/null @@ -1,30 +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 { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/kibana/instances`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/index.js b/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/index.js deleted file mode 100644 index 51a7e033bd0d6..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/kibana/instances/index.js +++ /dev/null @@ -1,83 +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, { Fragment } from 'react'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { KibanaInstances } from 'plugins/monitoring/components/kibana/instances'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { I18nContext } from 'ui/i18n'; -import { KIBANA_SYSTEM_ID, CODE_PATH_KIBANA } from '../../../../common/constants'; - -uiRoutes.when('/kibana/instances', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_KIBANA] }); - }, - pageData: getPageData, - }, - controllerAs: 'kibanas', - controller: class KibanaInstancesList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - super({ - title: 'Kibana Instances', - storageKey: 'kibana.instances', - getPageData, - reactNodeId: 'monitoringKibanaInstancesApp', - $scope, - $injector, - }); - - const kbnUrl = $injector.get('kbnUrl'); - - const renderReact = () => { - this.renderReact( - <I18nContext> - <SetupModeRenderer - scope={$scope} - injector={$injector} - productName={KIBANA_SYSTEM_ID} - render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( - <Fragment> - {flyoutComponent} - <KibanaInstances - instances={this.data.kibanas} - setupMode={setupMode} - sorting={this.sorting} - pagination={this.pagination} - onTableChange={this.onTableChange} - clusterStatus={this.data.clusterStatus} - angular={{ - $scope, - kbnUrl, - }} - /> - {bottomBarComponent} - </Fragment> - )} - /> - </I18nContext> - ); - }; - - $scope.$watch( - () => this.data, - data => { - if (!data) { - return; - } - - renderReact(); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/kibana/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/kibana/overview/index.js deleted file mode 100644 index 0705e3b7f270b..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/kibana/overview/index.js +++ /dev/null @@ -1,115 +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. - */ - -/** - * Kibana Overview - */ -import React from 'react'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { MonitoringTimeseriesContainer } from '../../../components/chart'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPanel, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { ClusterStatus } from '../../../components/kibana/cluster_status'; -import { I18nContext } from 'ui/i18n'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_KIBANA } from '../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/kibana`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/kibana', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_KIBANA] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringKibanaOverviewApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - title: `Kibana`, - defaultData: {}, - getPageData, - reactNodeId: 'monitoringKibanaOverviewApp', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - if (!data || !data.clusterStatus) { - return; - } - - this.renderReact( - <I18nContext> - <EuiPage> - <EuiPageBody> - <EuiPanel> - <ClusterStatus stats={data.clusterStatus} /> - </EuiPanel> - <EuiSpacer size="m" /> - <EuiPageContent> - <EuiFlexGroup> - <EuiFlexItem grow={true}> - <MonitoringTimeseriesContainer - series={data.metrics.kibana_cluster_requests} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - </EuiFlexItem> - <EuiFlexItem grow={true}> - <MonitoringTimeseriesContainer - series={data.metrics.kibana_cluster_response_times} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiPageContent> - </EuiPageBody> - </EuiPage> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/license/controller.js b/x-pack/legacy/plugins/monitoring/public/views/license/controller.js deleted file mode 100644 index ce6e9c8fb74cd..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/license/controller.js +++ /dev/null @@ -1,82 +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 { get, find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import chrome from 'plugins/monitoring/np_imports/ui/chrome'; -import { formatDateTimeLocal } from '../../../common/formatting'; -import { MANAGEMENT_BASE_PATH } from 'plugins/xpack_main/components'; -import { License } from 'plugins/monitoring/components'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { I18nContext } from 'ui/i18n'; - -const REACT_NODE_ID = 'licenseReact'; - -export class LicenseViewController { - constructor($injector, $scope) { - timefilter.disableTimeRangeSelector(); - timefilter.disableAutoRefreshSelector(); - - $scope.$on('$destroy', () => { - unmountComponentAtNode(document.getElementById(REACT_NODE_ID)); - }); - - this.init($injector, $scope, i18n); - } - - init($injector, $scope) { - const globalState = $injector.get('globalState'); - const title = $injector.get('title'); - const $route = $injector.get('$route'); - - const cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - $scope.cluster = cluster; - const routeTitle = i18n.translate('xpack.monitoring.license.licenseRouteTitle', { - defaultMessage: 'License', - }); - title($scope.cluster, routeTitle); - - this.license = cluster.license; - this.isExpired = Date.now() > get(cluster, 'license.expiry_date_in_millis'); - this.isPrimaryCluster = cluster.isPrimary; - - const basePath = chrome.getBasePath(); - this.uploadLicensePath = basePath + '/app/kibana#' + MANAGEMENT_BASE_PATH + 'upload_license'; - - this.renderReact($scope); - } - - renderReact($scope) { - const injector = chrome.dangerouslyGetActiveInjector(); - const timezone = injector.get('config').get('dateFormat:tz'); - $scope.$evalAsync(() => { - const { isPrimaryCluster, license, isExpired, uploadLicensePath } = this; - let expiryDate = license.expiry_date_in_millis; - if (license.expiry_date_in_millis !== undefined) { - expiryDate = formatDateTimeLocal(license.expiry_date_in_millis, timezone); - } - - // Mount the React component to the template - render( - <I18nContext> - <License - isPrimaryCluster={isPrimaryCluster} - status={license.status} - type={license.type} - isExpired={isExpired} - expiryDate={expiryDate} - uploadLicensePath={uploadLicensePath} - /> - </I18nContext>, - document.getElementById(REACT_NODE_ID) - ); - }); - } -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/license/index.js b/x-pack/legacy/plugins/monitoring/public/views/license/index.js deleted file mode 100644 index e0796c85d8f85..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/license/index.js +++ /dev/null @@ -1,23 +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 uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { LicenseViewController } from './controller'; -import { CODE_PATH_LICENSE } from '../../../common/constants'; - -uiRoutes.when('/license', { - template, - resolve: { - clusters: Private => { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LICENSE] }); - }, - }, - controllerAs: 'licenseView', - controller: LicenseViewController, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/loading/index.html b/x-pack/legacy/plugins/monitoring/public/views/loading/index.html deleted file mode 100644 index 11da26a0ceed2..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/loading/index.html +++ /dev/null @@ -1,5 +0,0 @@ -<monitoring-main name="loading"> - <div data-test-subj="loadingContainer"> - <div id="monitoringLoadingReactApp"></div> - </div> -</monitoring-main> diff --git a/x-pack/legacy/plugins/monitoring/public/views/loading/index.js b/x-pack/legacy/plugins/monitoring/public/views/loading/index.js deleted file mode 100644 index 0488683845a7d..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/loading/index.js +++ /dev/null @@ -1,51 +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 { render, unmountComponentAtNode } from 'react-dom'; -import { PageLoading } from 'plugins/monitoring/components'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { I18nContext } from 'ui/i18n'; -import template from './index.html'; -import { CODE_PATH_LICENSE } from '../../../common/constants'; - -const REACT_DOM_ID = 'monitoringLoadingReactApp'; - -uiRoutes.when('/loading', { - template, - controller: class { - constructor($injector, $scope) { - const monitoringClusters = $injector.get('monitoringClusters'); - const kbnUrl = $injector.get('kbnUrl'); - - $scope.$on('$destroy', () => { - unmountComponentAtNode(document.getElementById(REACT_DOM_ID)); - }); - - $scope.$$postDigest(() => { - this.renderReact(); - }); - - monitoringClusters(undefined, undefined, [CODE_PATH_LICENSE]).then(clusters => { - if (clusters && clusters.length) { - kbnUrl.changePath('/home'); - return; - } - kbnUrl.changePath('/no-data'); - return; - }); - } - - renderReact() { - render( - <I18nContext> - <PageLoading /> - </I18nContext>, - document.getElementById(REACT_DOM_ID) - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/advanced/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/node/advanced/index.js deleted file mode 100644 index 29cf4839eff94..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/advanced/index.js +++ /dev/null @@ -1,130 +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. - */ - -/* - * Logstash Node Advanced View - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { MonitoringViewBaseController } from '../../../base_controller'; -import { DetailStatus } from 'plugins/monitoring/components/logstash/detail_status'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPanel, - EuiSpacer, - EuiFlexGrid, - EuiFlexItem, -} from '@elastic/eui'; -import { MonitoringTimeseriesContainer } from '../../../../components/chart'; -import { I18nContext } from 'ui/i18n'; -import { CODE_PATH_LOGSTASH } from '../../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/node/${$route.current.params.uuid}`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: true, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/logstash/node/:uuid/advanced', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - defaultData: {}, - getPageData, - reactNodeId: 'monitoringLogstashNodeAdvancedApp', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - if (!data || !data.nodeSummary) { - return; - } - - this.setTitle( - i18n.translate('xpack.monitoring.logstash.node.advanced.routeTitle', { - defaultMessage: 'Logstash - {nodeName} - Advanced', - values: { - nodeName: data.nodeSummary.name, - }, - }) - ); - - const metricsToShow = [ - data.metrics.logstash_node_cpu_utilization, - data.metrics.logstash_queue_events_count, - data.metrics.logstash_node_cgroup_cpu, - data.metrics.logstash_pipeline_queue_size, - data.metrics.logstash_node_cgroup_stats, - ]; - - this.renderReact( - <I18nContext> - <EuiPage> - <EuiPageBody> - <EuiPanel> - <DetailStatus stats={data.nodeSummary} /> - </EuiPanel> - <EuiSpacer size="m" /> - <EuiPageContent> - <EuiFlexGrid columns={2} gutterSize="s"> - {metricsToShow.map((metric, index) => ( - <EuiFlexItem key={index}> - <MonitoringTimeseriesContainer - series={metric} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - {...data} - /> - <EuiSpacer /> - </EuiFlexItem> - ))} - </EuiFlexGrid> - </EuiPageContent> - </EuiPageBody> - </EuiPage> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/node/index.js deleted file mode 100644 index f1777d1e46ef0..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/index.js +++ /dev/null @@ -1,131 +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. - */ - -/* - * Logstash Node - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { DetailStatus } from 'plugins/monitoring/components/logstash/detail_status'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPanel, - EuiSpacer, - EuiFlexGrid, - EuiFlexItem, -} from '@elastic/eui'; -import { MonitoringTimeseriesContainer } from '../../../components/chart'; -import { I18nContext } from 'ui/i18n'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_LOGSTASH } from '../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/node/${$route.current.params.uuid}`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: false, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/logstash/node/:uuid', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - defaultData: {}, - getPageData, - reactNodeId: 'monitoringLogstashNodeApp', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - if (!data || !data.nodeSummary) { - return; - } - - this.setTitle( - i18n.translate('xpack.monitoring.logstash.node.routeTitle', { - defaultMessage: 'Logstash - {nodeName}', - values: { - nodeName: data.nodeSummary.name, - }, - }) - ); - - const metricsToShow = [ - data.metrics.logstash_events_input_rate, - data.metrics.logstash_jvm_usage, - data.metrics.logstash_events_output_rate, - data.metrics.logstash_node_cpu_metric, - data.metrics.logstash_events_latency, - data.metrics.logstash_os_load, - ]; - - this.renderReact( - <I18nContext> - <EuiPage> - <EuiPageBody> - <EuiPanel> - <DetailStatus stats={data.nodeSummary} /> - </EuiPanel> - <EuiSpacer size="m" /> - <EuiPageContent> - <EuiFlexGrid columns={2} gutterSize="s"> - {metricsToShow.map((metric, index) => ( - <EuiFlexItem key={index}> - <MonitoringTimeseriesContainer - series={metric} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - {...data} - /> - <EuiSpacer /> - </EuiFlexItem> - ))} - </EuiFlexGrid> - </EuiPageContent> - </EuiPageBody> - </EuiPage> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js deleted file mode 100644 index 017988b70bdd4..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/node/pipelines/index.js +++ /dev/null @@ -1,132 +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. - */ - -/* - * Logstash Node Pipelines Listing - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { isPipelineMonitoringSupportedInVersion } from 'plugins/monitoring/lib/logstash/pipelines'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { MonitoringViewBaseEuiTableController } from '../../../'; -import { I18nContext } from 'ui/i18n'; -import { PipelineListing } from '../../../../components/logstash/pipeline_listing/pipeline_listing'; -import { DetailStatus } from '../../../../components/logstash/detail_status'; -import { CODE_PATH_LOGSTASH } from '../../../../../common/constants'; - -const getPageData = ($injector, _api = undefined, routeOptions = {}) => { - _api; // fixing eslint - const $route = $injector.get('$route'); - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const Private = $injector.get('Private'); - - const logstashUuid = $route.current.params.uuid; - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/node/${logstashUuid}/pipelines`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - ...routeOptions, - }) - .then(response => response.data) - .catch(err => { - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -}; - -function makeUpgradeMessage(logstashVersion) { - if (isPipelineMonitoringSupportedInVersion(logstashVersion)) { - return null; - } - - return i18n.translate('xpack.monitoring.logstash.node.pipelines.notAvailableDescription', { - defaultMessage: - 'Pipeline monitoring is only available in Logstash version 6.0.0 or higher. This node is running version {logstashVersion}.', - values: { - logstashVersion, - }, - }); -} - -uiRoutes.when('/logstash/node/:uuid/pipelines', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - }, - controller: class extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const kbnUrl = $injector.get('kbnUrl'); - const config = $injector.get('config'); - - super({ - defaultData: {}, - getPageData, - reactNodeId: 'monitoringLogstashNodePipelinesApp', - $scope, - $injector, - fetchDataImmediately: false, // We want to apply pagination before sending the first request - }); - - $scope.$watch( - () => this.data, - data => { - if (!data || !data.nodeSummary) { - return; - } - - this.setTitle( - i18n.translate('xpack.monitoring.logstash.node.pipelines.routeTitle', { - defaultMessage: 'Logstash - {nodeName} - Pipelines', - values: { - nodeName: data.nodeSummary.name, - }, - }) - ); - - const pagination = { - ...this.pagination, - totalItemCount: data.totalPipelineCount, - }; - - this.renderReact( - <I18nContext> - <PipelineListing - className="monitoringLogstashPipelinesTable" - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - stats={data.nodeSummary} - statusComponent={DetailStatus} - data={data.pipelines} - {...this.getPaginationTableProps(pagination)} - dateFormat={config.get('dateFormat')} - upgradeMessage={makeUpgradeMessage(data.nodeSummary.version, i18n)} - angular={{ - kbnUrl, - scope: $scope, - }} - /> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/get_page_data.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/get_page_data.js deleted file mode 100644 index d476f6ba5143e..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/get_page_data.js +++ /dev/null @@ -1,30 +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 { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/nodes`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/index.js deleted file mode 100644 index 30f851b2a7534..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/nodes/index.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { Fragment } from 'react'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { I18nContext } from 'ui/i18n'; -import { Listing } from '../../../components/logstash/listing'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { CODE_PATH_LOGSTASH, LOGSTASH_SYSTEM_ID } from '../../../../common/constants'; - -uiRoutes.when('/logstash/nodes', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controllerAs: 'lsNodes', - controller: class LsNodesList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const kbnUrl = $injector.get('kbnUrl'); - - super({ - title: 'Logstash - Nodes', - storageKey: 'logstash.nodes', - getPageData, - reactNodeId: 'monitoringLogstashNodesApp', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - this.renderReact( - <I18nContext> - <SetupModeRenderer - scope={$scope} - injector={$injector} - productName={LOGSTASH_SYSTEM_ID} - render={({ setupMode, flyoutComponent, bottomBarComponent }) => ( - <Fragment> - {flyoutComponent} - <Listing - data={data.nodes} - setupMode={setupMode} - stats={data.clusterStatus} - sorting={this.sorting} - pagination={this.pagination} - onTableChange={this.onTableChange} - angular={{ kbnUrl, scope: $scope }} - /> - {bottomBarComponent} - </Fragment> - )} - /> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/overview/index.js deleted file mode 100644 index f41f54555952e..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/overview/index.js +++ /dev/null @@ -1,79 +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. - */ - -/** - * Logstash Overview - */ -import React from 'react'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { I18nContext } from 'ui/i18n'; -import { Overview } from '../../../components/logstash/overview'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_LOGSTASH } from '../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then(response => response.data) - .catch(err => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/logstash', { - template, - resolve: { - clusters: function(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - title: 'Logstash', - getPageData, - reactNodeId: 'monitoringLogstashOverviewApp', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - data => { - this.renderReact( - <I18nContext> - <Overview - stats={data.clusterStatus} - metrics={data.metrics} - onBrush={this.onBrush} - zoomInfo={this.zoomInfo} - /> - </I18nContext> - ); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/pipeline/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/pipeline/index.js deleted file mode 100644 index 11cb8516847c8..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/pipeline/index.js +++ /dev/null @@ -1,175 +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. - */ - -/* - * Logstash Node Pipeline View - */ -import React from 'react'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import moment from 'moment'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { CALCULATE_DURATION_SINCE, CODE_PATH_LOGSTASH } from '../../../../common/constants'; -import { formatTimestampToDuration } from '../../../../common/format_timestamp_to_duration'; -import template from './index.html'; -import { i18n } from '@kbn/i18n'; -import { List } from 'plugins/monitoring/components/logstash/pipeline_viewer/models/list'; -import { PipelineState } from 'plugins/monitoring/components/logstash/pipeline_viewer/models/pipeline_state'; -import { PipelineViewer } from 'plugins/monitoring/components/logstash/pipeline_viewer'; -import { Pipeline } from 'plugins/monitoring/components/logstash/pipeline_viewer/models/pipeline'; -import { vertexFactory } from 'plugins/monitoring/components/logstash/pipeline_viewer/models/graph/vertex_factory'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { I18nContext } from 'ui/i18n'; -import { EuiPageBody, EuiPage, EuiPageContent } from '@elastic/eui'; - -let previousPipelineHash = undefined; -let detailVertexId = undefined; - -function getPageData($injector) { - const $route = $injector.get('$route'); - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const minIntervalSeconds = $injector.get('minIntervalSeconds'); - const Private = $injector.get('Private'); - - const { ccs, cluster_uuid: clusterUuid } = globalState; - const pipelineId = $route.current.params.id; - const pipelineHash = $route.current.params.hash || ''; - - // Pipeline version was changed, so clear out detailVertexId since that vertex won't - // exist in the updated pipeline version - if (pipelineHash !== previousPipelineHash) { - previousPipelineHash = pipelineHash; - detailVertexId = undefined; - } - - const url = pipelineHash - ? `../api/monitoring/v1/clusters/${clusterUuid}/logstash/pipeline/${pipelineId}/${pipelineHash}` - : `../api/monitoring/v1/clusters/${clusterUuid}/logstash/pipeline/${pipelineId}`; - return $http - .post(url, { - ccs, - detailVertexId, - }) - .then(response => response.data) - .then(data => { - data.versions = data.versions.map(version => { - const relativeFirstSeen = formatTimestampToDuration( - version.firstSeen, - CALCULATE_DURATION_SINCE - ); - const relativeLastSeen = formatTimestampToDuration( - version.lastSeen, - CALCULATE_DURATION_SINCE - ); - - const fudgeFactorSeconds = 2 * minIntervalSeconds; - const isLastSeenCloseToNow = Date.now() - version.lastSeen <= fudgeFactorSeconds * 1000; - - return { - ...version, - relativeFirstSeen: i18n.translate( - 'xpack.monitoring.logstash.pipeline.relativeFirstSeenAgoLabel', - { - defaultMessage: '{relativeFirstSeen} ago', - values: { relativeFirstSeen }, - } - ), - relativeLastSeen: isLastSeenCloseToNow - ? i18n.translate('xpack.monitoring.logstash.pipeline.relativeLastSeenNowLabel', { - defaultMessage: 'now', - }) - : i18n.translate('xpack.monitoring.logstash.pipeline.relativeLastSeenAgoLabel', { - defaultMessage: 'until {relativeLastSeen} ago', - values: { relativeLastSeen }, - }), - }; - }); - - return data; - }) - .catch(err => { - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/logstash/pipelines/:id/:hash?', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const config = $injector.get('config'); - const dateFormat = config.get('dateFormat'); - - super({ - title: i18n.translate('xpack.monitoring.logstash.pipeline.routeTitle', { - defaultMessage: 'Logstash - Pipeline', - }), - storageKey: 'logstash.pipelines', - getPageData, - reactNodeId: 'monitoringLogstashPipelineApp', - $scope, - options: { - enableTimeFilter: false, - }, - $injector, - }); - - const timeseriesTooltipXValueFormatter = xValue => moment(xValue).format(dateFormat); - - const setDetailVertexId = vertex => { - if (!vertex) { - detailVertexId = undefined; - } else { - detailVertexId = vertex.id; - } - - return this.updateData(); - }; - - $scope.$watch( - () => this.data, - data => { - if (!data || !data.pipeline) { - return; - } - this.pipelineState = new PipelineState(data.pipeline); - this.detailVertex = data.vertex ? vertexFactory(null, data.vertex) : null; - this.renderReact( - <I18nContext> - <EuiPage> - <EuiPageBody> - <EuiPageContent> - <PipelineViewer - pipeline={List.fromPipeline( - Pipeline.fromPipelineGraph(this.pipelineState.config.graph) - )} - timeseriesTooltipXValueFormatter={timeseriesTooltipXValueFormatter} - setDetailVertexId={setDetailVertexId} - detailVertex={this.detailVertex} - /> - </EuiPageContent> - </EuiPageBody> - </EuiPage> - </I18nContext> - ); - } - ); - - $scope.$on('$destroy', () => { - previousPipelineHash = undefined; - detailVertexId = undefined; - }); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/logstash/pipelines/index.js b/x-pack/legacy/plugins/monitoring/public/views/logstash/pipelines/index.js deleted file mode 100644 index 75a18000c14dd..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/logstash/pipelines/index.js +++ /dev/null @@ -1,132 +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 { find } from 'lodash'; -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import { ajaxErrorHandlersProvider } from 'plugins/monitoring/lib/ajax_error_handler'; -import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; -import { isPipelineMonitoringSupportedInVersion } from 'plugins/monitoring/lib/logstash/pipelines'; -import template from './index.html'; -import { timefilter } from 'plugins/monitoring/np_imports/ui/timefilter'; -import { I18nContext } from 'ui/i18n'; -import { PipelineListing } from '../../../components/logstash/pipeline_listing/pipeline_listing'; -import { MonitoringViewBaseEuiTableController } from '../..'; -import { CODE_PATH_LOGSTASH } from '../../../../common/constants'; - -/* - * Logstash Pipelines Listing page - */ - -const getPageData = ($injector, _api = undefined, routeOptions = {}) => { - _api; // to fix eslint - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const Private = $injector.get('Private'); - - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/pipelines`; - const timeBounds = timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - ...routeOptions, - }) - .then(response => response.data) - .catch(err => { - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -}; - -function makeUpgradeMessage(logstashVersions) { - if ( - !Array.isArray(logstashVersions) || - logstashVersions.length === 0 || - logstashVersions.some(isPipelineMonitoringSupportedInVersion) - ) { - return null; - } - - return 'Pipeline monitoring is only available in Logstash version 6.0.0 or higher.'; -} - -uiRoutes.when('/logstash/pipelines', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - }, - controller: class LogstashPipelinesList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - super({ - title: 'Logstash Pipelines', - storageKey: 'logstash.pipelines', - getPageData, - reactNodeId: 'monitoringLogstashPipelinesApp', - $scope, - $injector, - fetchDataImmediately: false, // We want to apply pagination before sending the first request - }); - - const $route = $injector.get('$route'); - const kbnUrl = $injector.get('kbnUrl'); - const config = $injector.get('config'); - this.data = $route.current.locals.pageData; - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - const renderReact = pageData => { - if (!pageData) { - return; - } - - const upgradeMessage = pageData - ? makeUpgradeMessage(pageData.clusterStatus.versions, i18n) - : null; - - const pagination = { - ...this.pagination, - totalItemCount: pageData.totalPipelineCount, - }; - - super.renderReact( - <I18nContext> - <PipelineListing - className="monitoringLogstashPipelinesTable" - onBrush={xaxis => this.onBrush({ xaxis })} - stats={pageData.clusterStatus} - data={pageData.pipelines} - {...this.getPaginationTableProps(pagination)} - upgradeMessage={upgradeMessage} - dateFormat={config.get('dateFormat')} - angular={{ - kbnUrl, - scope: $scope, - }} - /> - </I18nContext> - ); - }; - - $scope.$watch( - () => this.data, - pageData => { - renderReact(pageData); - } - ); - } - }, -}); diff --git a/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js b/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js deleted file mode 100644 index a914aa0155e90..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js +++ /dev/null @@ -1,116 +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 { - ClusterSettingsChecker, - NodeSettingsChecker, - Enabler, - startChecks, -} from 'plugins/monitoring/lib/elasticsearch_settings'; -import { ModelUpdater } from './model_updater'; -import { NoData } from 'plugins/monitoring/components'; -import { I18nContext } from 'ui/i18n'; -import { CODE_PATH_LICENSE } from '../../../common/constants'; -import { MonitoringViewBaseController } from '../base_controller'; -import { i18n } from '@kbn/i18n'; -import { npSetup } from 'ui/new_platform'; - -export class NoDataController extends MonitoringViewBaseController { - constructor($injector, $scope) { - const monitoringClusters = $injector.get('monitoringClusters'); - const kbnUrl = $injector.get('kbnUrl'); - const $http = $injector.get('$http'); - const checkers = [new ClusterSettingsChecker($http), new NodeSettingsChecker($http)]; - - const getData = async () => { - let catchReason; - try { - const monitoringClustersData = await monitoringClusters(undefined, undefined, [ - CODE_PATH_LICENSE, - ]); - if (monitoringClustersData && monitoringClustersData.length) { - kbnUrl.redirect('/home'); - return monitoringClustersData; - } - } catch (err) { - if (err && err.status === 503) { - catchReason = { - property: 'custom', - message: err.data.message, - }; - } - } - - this.errors.length = 0; - if (catchReason) { - this.reason = catchReason; - } else if (!this.isCollectionEnabledUpdating && !this.isCollectionIntervalUpdating) { - /** - * `no-use-before-define` is fine here, since getData is an async function. - * Needs to be done this way, since there is no `this` before super is executed - * */ - await startChecks(checkers, updateModel); // eslint-disable-line no-use-before-define - } - }; - - super({ - title: i18n.translate('xpack.monitoring.noData.routeTitle', { - defaultMessage: 'Setup Monitoring', - }), - getPageData: async () => await getData(), - reactNodeId: 'noDataReact', - $scope, - $injector, - }); - Object.assign(this, this.getDefaultModel()); - - //Need to set updateModel after super since there is no `this` otherwise - const { updateModel } = new ModelUpdater($scope, this); - const enabler = new Enabler($http, updateModel); - $scope.$watch( - () => this, - () => { - if (this.isCollectionEnabledUpdated && !this.reason) { - return; - } - this.render(enabler); - }, - true - ); - - this.changePath = path => kbnUrl.changePath(path); - } - - getDefaultModel() { - return { - errors: [], // errors can happen from trying to check or set ES settings - checkMessage: null, // message to show while waiting for api response - isLoading: true, // flag for in-progress state of checking for no data reason - isCollectionEnabledUpdating: false, // flags to indicate whether to show a spinner while waiting for ajax - isCollectionEnabledUpdated: false, - isCollectionIntervalUpdating: false, - isCollectionIntervalUpdated: false, - }; - } - - render(enabler) { - const props = this; - const { cloud } = npSetup.plugins; - const isCloudEnabled = !!(cloud && cloud.isCloudEnabled); - - this.renderReact( - <I18nContext> - <NoData - {...props} - enabler={enabler} - changePath={this.changePath} - isCloudEnabled={isCloudEnabled} - /> - </I18nContext> - ); - } -} diff --git a/x-pack/legacy/plugins/monitoring/public/views/no_data/index.js b/x-pack/legacy/plugins/monitoring/public/views/no_data/index.js deleted file mode 100644 index edade513e5ab2..0000000000000 --- a/x-pack/legacy/plugins/monitoring/public/views/no_data/index.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import uiRoutes from 'plugins/monitoring/np_imports/ui/routes'; -import template from './index.html'; -import { NoDataController } from './controller'; - -uiRoutes - .when('/no-data', { - template, - controller: NoDataController, - }) - .otherwise({ redirectTo: '/home' }); diff --git a/x-pack/legacy/plugins/monitoring/ui_exports.js b/x-pack/legacy/plugins/monitoring/ui_exports.js deleted file mode 100644 index e0c04411ef46b..0000000000000 --- a/x-pack/legacy/plugins/monitoring/ui_exports.js +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { resolve } from 'path'; -import { - MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS, - KIBANA_ALERTING_ENABLED, -} from './common/constants'; -import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; - -/** - * Configuration of dependency objects for the UI, which are needed for the - * Monitoring UI app and views and data for outside the monitoring - * app (injectDefaultVars and hacks) - * @return {Object} data per Kibana plugin uiExport schema - */ -export const getUiExports = () => { - const uiSettingDefaults = {}; - if (KIBANA_ALERTING_ENABLED) { - uiSettingDefaults[MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS] = { - name: i18n.translate('xpack.monitoring.alertingEmailAddress.name', { - defaultMessage: 'Alerting email address', - }), - value: '', - description: i18n.translate('xpack.monitoring.alertingEmailAddress.description', { - defaultMessage: `The default email address to receive alerts from Stack Monitoring`, - }), - category: ['monitoring'], - }; - } - - return { - app: { - title: i18n.translate('xpack.monitoring.stackMonitoringTitle', { - defaultMessage: 'Stack Monitoring', - }), - order: 9002, - description: i18n.translate('xpack.monitoring.uiExportsDescription', { - defaultMessage: 'Monitoring for Elastic Stack', - }), - icon: 'plugins/monitoring/icons/monitoring.svg', - euiIconType: 'monitoringApp', - linkToLastSubUrl: false, - main: 'plugins/monitoring/legacy', - category: DEFAULT_APP_CATEGORIES.management, - }, - injectDefaultVars(server) { - const config = server.config(); - return { - monitoringUiEnabled: config.get('monitoring.ui.enabled'), - monitoringLegacyEmailAddress: config.get( - 'monitoring.cluster_alerts.email_notifications.email_address' - ), - }; - }, - uiSettingDefaults, - hacks: ['plugins/monitoring/hacks/toggle_app_link_in_nav'], - home: ['plugins/monitoring/register_feature'], - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - }; -}; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts index 254cfbaa878bd..6c56c269017e2 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts @@ -9,4 +9,4 @@ export const LayoutTypes = { PRINT: 'print', }; -export const PAGELOAD_SELECTOR = '.application'; +export const DEFAULT_PAGELOAD_SELECTOR = '.application'; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 68d660257a56d..796bccb360ebd 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -98,9 +98,12 @@ describe('Screenshot Observable Pipeline', () => { return Promise.resolve(`allyourBase64 screenshots`); }); + const mockOpen = jest.fn(); + // mocks mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, { screenshot: mockScreenshot, + open: mockOpen, }); // test @@ -179,6 +182,15 @@ describe('Screenshot Observable Pipeline', () => { }, ] `); + + // ensures the correct selectors are waited on for multi URL jobs + expect(mockOpen.mock.calls.length).toBe(2); + + const firstSelector = mockOpen.mock.calls[0][1].waitForSelector; + expect(firstSelector).toBe('.application'); + + const secondSelector = mockOpen.mock.calls[1][1].waitForSelector; + expect(secondSelector).toBe('[data-shared-page="2"]'); }); describe('error handling', () => { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index c6861ae1d17ad..eb96753f0ce18 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -7,6 +7,7 @@ import * as Rx from 'rxjs'; import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; import { CaptureConfig } from '../../../../server/types'; +import { DEFAULT_PAGELOAD_SELECTOR } from '../../constants'; import { HeadlessChromiumDriverFactory } from '../../../../types'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; @@ -44,13 +45,29 @@ export function screenshotsObservableFactory( { viewport: layout.getBrowserViewport(), browserTimezone }, logger ); - return Rx.from(urls).pipe( - concatMap(url => { - return create$.pipe( - mergeMap(({ driver, exit$ }) => { + + return create$.pipe( + mergeMap(({ driver, exit$ }) => { + return Rx.from(urls).pipe( + concatMap((url, index) => { const setup$: Rx.Observable<ScreenSetupData> = Rx.of(1).pipe( takeUntil(exit$), - mergeMap(() => openUrl(captureConfig, driver, url, conditionalHeaders, logger)), + mergeMap(() => { + // If we're moving to another page in the app, we'll want to wait for the app to tell us + // it's loaded the next page. + const page = index + 1; + const pageLoadSelector = + page > 1 ? `[data-shared-page="${page}"]` : DEFAULT_PAGELOAD_SELECTOR; + + return openUrl( + captureConfig, + driver, + url, + pageLoadSelector, + conditionalHeaders, + logger + ); + }), mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), mergeMap(async itemsCount => { const viewport = layout.getViewport(itemsCount) || getDefaultViewPort(); @@ -104,11 +121,11 @@ export function screenshotsObservableFactory( ) ); }), - first() + take(urls.length), + toArray() ); }), - take(urls.length), - toArray() + first() ); }; } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index a484dfb243563..92a58aded5f66 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -9,12 +9,12 @@ import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/br import { LevelLogger } from '../../../../server/lib'; import { CaptureConfig } from '../../../../server/types'; import { ConditionalHeaders } from '../../../../types'; -import { PAGELOAD_SELECTOR } from '../../constants'; export const openUrl = async ( captureConfig: CaptureConfig, browser: HeadlessBrowser, url: string, + pageLoadSelector: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise<void> => { @@ -23,7 +23,7 @@ export const openUrl = async ( url, { conditionalHeaders, - waitForSelector: PAGELOAD_SELECTOR, + waitForSelector: pageLoadSelector, timeout: captureConfig.timeouts.openUrl, }, logger diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index dfaa87021c31c..dd20e849d97a9 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { map, trunc } from 'lodash'; import open from 'opn'; -import { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle } from 'puppeteer'; +import { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle, Response } from 'puppeteer'; import { parse as parseUrl } from 'url'; import { ViewZoomWidthHeight } from '../../../../export_types/common/layouts/layout'; import { LevelLogger } from '../../../../server/lib'; @@ -45,6 +45,9 @@ export class HeadlessChromiumDriver { private readonly inspect: boolean; private readonly networkPolicy: NetworkPolicy; + private listenersAttached = false; + private interceptedCount = 0; + constructor(page: Page, { inspect, networkPolicy }: ChromiumDriverOptions) { this.page = page; this.inspect = inspect; @@ -76,103 +79,13 @@ export class HeadlessChromiumDriver { logger: LevelLogger ): Promise<void> { logger.info(`opening url ${url}`); - // @ts-ignore - const client = this.page._client; - let interceptedCount = 0; - - await this.page.setRequestInterception(true); - - // We have to reach into the Chrome Devtools Protocol to apply headers as using - // puppeteer's API will cause map tile requests to hang indefinitely: - // https://github.com/puppeteer/puppeteer/issues/5003 - // Docs on this client/protocol can be found here: - // https://chromedevtools.github.io/devtools-protocol/tot/Fetch - client.on('Fetch.requestPaused', async (interceptedRequest: InterceptedRequest) => { - const { - requestId, - request: { url: interceptedUrl }, - } = interceptedRequest; - const allowed = !interceptedUrl.startsWith('file://'); - const isData = interceptedUrl.startsWith('data:'); - - // We should never ever let file protocol requests go through - if (!allowed || !this.allowRequest(interceptedUrl)) { - logger.error(`Got bad URL: "${interceptedUrl}", closing browser.`); - await client.send('Fetch.failRequest', { - errorReason: 'Aborted', - requestId, - }); - this.page.browser().close(); - throw new Error( - i18n.translate('xpack.reporting.chromiumDriver.disallowedOutgoingUrl', { - defaultMessage: `Received disallowed outgoing URL: "{interceptedUrl}", exiting`, - values: { interceptedUrl }, - }) - ); - } - - if (this._shouldUseCustomHeaders(conditionalHeaders.conditions, interceptedUrl)) { - logger.debug(`Using custom headers for ${interceptedUrl}`); - const headers = map( - { - ...interceptedRequest.request.headers, - ...conditionalHeaders.headers, - }, - (value, name) => ({ - name, - value, - }) - ); - - try { - await client.send('Fetch.continueRequest', { - requestId, - headers, - }); - } catch (err) { - logger.error( - i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders', { - defaultMessage: 'Failed to complete a request using headers: {error}', - values: { error: err }, - }) - ); - } - } else { - const loggedUrl = isData ? this.truncateUrl(interceptedUrl) : interceptedUrl; - logger.debug(`No custom headers for ${loggedUrl}`); - try { - await client.send('Fetch.continueRequest', { requestId }); - } catch (err) { - logger.error( - i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequest', { - defaultMessage: 'Failed to complete a request: {error}', - values: { error: err }, - }) - ); - } - } - interceptedCount = interceptedCount + (isData ? 0 : 1); - }); - // Even though 3xx redirects go through our request - // handler, we should probably inspect responses just to - // avoid being bamboozled by some malicious request - this.page.on('response', interceptedResponse => { - const interceptedUrl = interceptedResponse.url(); - const allowed = !interceptedUrl.startsWith('file://'); + // Reset intercepted request count + this.interceptedCount = 0; - if (!interceptedResponse.ok()) { - logger.warn( - `Chromium received a non-OK response (${interceptedResponse.status()}) for request ${interceptedUrl}` - ); - } + await this.page.setRequestInterception(true); - if (!allowed || !this.allowRequest(interceptedUrl)) { - logger.error(`Got disallowed URL "${interceptedUrl}", closing browser.`); - this.page.browser().close(); - throw new Error(`Received disallowed URL in response: ${interceptedUrl}`); - } - }); + this.registerListeners(conditionalHeaders, logger); await this.page.goto(url, { waitUntil: 'domcontentloaded' }); @@ -186,7 +99,7 @@ export class HeadlessChromiumDriver { { context: 'waiting for page load selector' }, logger ); - logger.info(`handled ${interceptedCount} page requests`); + logger.info(`handled ${this.interceptedCount} page requests`); } public async screenshot(elementPosition: ElementPosition): Promise<string> { @@ -272,6 +185,111 @@ export class HeadlessChromiumDriver { }); } + private registerListeners(conditionalHeaders: ConditionalHeaders, logger: LevelLogger) { + if (this.listenersAttached) { + return; + } + + // @ts-ignore + const client = this.page._client; + + // We have to reach into the Chrome Devtools Protocol to apply headers as using + // puppeteer's API will cause map tile requests to hang indefinitely: + // https://github.com/puppeteer/puppeteer/issues/5003 + // Docs on this client/protocol can be found here: + // https://chromedevtools.github.io/devtools-protocol/tot/Fetch + client.on('Fetch.requestPaused', async (interceptedRequest: InterceptedRequest) => { + const { + requestId, + request: { url: interceptedUrl }, + } = interceptedRequest; + + const allowed = !interceptedUrl.startsWith('file://'); + const isData = interceptedUrl.startsWith('data:'); + + // We should never ever let file protocol requests go through + if (!allowed || !this.allowRequest(interceptedUrl)) { + logger.error(`Got bad URL: "${interceptedUrl}", closing browser.`); + await client.send('Fetch.failRequest', { + errorReason: 'Aborted', + requestId, + }); + this.page.browser().close(); + throw new Error( + i18n.translate('xpack.reporting.chromiumDriver.disallowedOutgoingUrl', { + defaultMessage: `Received disallowed outgoing URL: "{interceptedUrl}", exiting`, + values: { interceptedUrl }, + }) + ); + } + + if (this._shouldUseCustomHeaders(conditionalHeaders.conditions, interceptedUrl)) { + logger.debug(`Using custom headers for ${interceptedUrl}`); + const headers = map( + { + ...interceptedRequest.request.headers, + ...conditionalHeaders.headers, + }, + (value, name) => ({ + name, + value, + }) + ); + + try { + await client.send('Fetch.continueRequest', { + requestId, + headers, + }); + } catch (err) { + logger.error( + i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders', { + defaultMessage: 'Failed to complete a request using headers: {error}', + values: { error: err }, + }) + ); + } + } else { + const loggedUrl = isData ? this.truncateUrl(interceptedUrl) : interceptedUrl; + logger.debug(`No custom headers for ${loggedUrl}`); + try { + await client.send('Fetch.continueRequest', { requestId }); + } catch (err) { + logger.error( + i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequest', { + defaultMessage: 'Failed to complete a request: {error}', + values: { error: err }, + }) + ); + } + } + + this.interceptedCount = this.interceptedCount + (isData ? 0 : 1); + }); + + // Even though 3xx redirects go through our request + // handler, we should probably inspect responses just to + // avoid being bamboozled by some malicious request + this.page.on('response', (interceptedResponse: Response) => { + const interceptedUrl = interceptedResponse.url(); + const allowed = !interceptedUrl.startsWith('file://'); + + if (!interceptedResponse.ok()) { + logger.warn( + `Chromium received a non-OK response (${interceptedResponse.status()}) for request ${interceptedUrl}` + ); + } + + if (!allowed || !this.allowRequest(interceptedUrl)) { + logger.error(`Got disallowed URL "${interceptedUrl}", closing browser.`); + this.page.browser().close(); + throw new Error(`Received disallowed URL in response: ${interceptedUrl}`); + } + }); + + this.listenersAttached = true; + } + private async launchDebugger() { // In order to pause on execution we have to reach more deeply into Chromiums Devtools Protocol, // and more specifically, for the page being used. _client is per-page, and puppeteer doesn't expose diff --git a/x-pack/legacy/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap b/x-pack/legacy/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap index aa22c3f66df18..ed2637d7a1bcb 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap +++ b/x-pack/legacy/plugins/reporting/server/usage/__snapshots__/reporting_usage_collector.test.ts.snap @@ -42,34 +42,6 @@ Object { }, "statuses": Object {}, }, - "lastDay": Object { - "PNG": Object { - "available": true, - "total": 0, - }, - "_all": 0, - "csv": Object { - "available": true, - "total": 0, - }, - "printable_pdf": Object { - "app": Object { - "dashboard": 0, - "visualization": 0, - }, - "available": true, - "layout": Object { - "preserve_layout": 0, - "print": 0, - }, - "total": 0, - }, - "status": Object { - "completed": 0, - "failed": 0, - }, - "statuses": Object {}, - }, "printable_pdf": Object { "app": Object { "dashboard": 0, @@ -139,41 +111,6 @@ Object { }, }, }, - "lastDay": Object { - "PNG": Object { - "available": true, - "total": 1, - }, - "_all": 1, - "csv": Object { - "available": true, - "total": 0, - }, - "printable_pdf": Object { - "app": Object { - "dashboard": 0, - "visualization": 0, - }, - "available": true, - "layout": Object { - "preserve_layout": 0, - "print": 0, - }, - "total": 0, - }, - "status": Object { - "completed": 0, - "completed_with_warnings": 1, - "failed": 0, - }, - "statuses": Object { - "completed_with_warnings": Object { - "PNG": Object { - "dashboard": 1, - }, - }, - }, - }, "printable_pdf": Object { "app": Object { "canvas workpad": 6, @@ -270,46 +207,6 @@ Object { }, }, }, - "lastDay": Object { - "PNG": Object { - "available": true, - "total": 1, - }, - "_all": 4, - "csv": Object { - "available": true, - "total": 1, - }, - "printable_pdf": Object { - "app": Object { - "canvas workpad": 1, - "dashboard": 1, - "visualization": 0, - }, - "available": true, - "layout": Object { - "preserve_layout": 2, - "print": 0, - }, - "total": 2, - }, - "status": Object { - "completed": 4, - "failed": 0, - }, - "statuses": Object { - "completed": Object { - "PNG": Object { - "dashboard": 1, - }, - "csv": Object {}, - "printable_pdf": Object { - "canvas workpad": 1, - "dashboard": 1, - }, - }, - }, - }, "printable_pdf": Object { "app": Object { "canvas workpad": 1, diff --git a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts index 2c3bb8f4bf71c..eb907d52c5f96 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts @@ -16,8 +16,11 @@ import { JobTypes, KeyCountBucket, RangeStats, + ReportingUsageType, SearchResponse, StatusByAppBucket, + AppCounts, + LayoutCounts, } from './types'; type XPackInfo = XPackMainPlugin['info']; @@ -36,8 +39,11 @@ const DEFAULT_TERMS_SIZE = 10; const PRINTABLE_PDF_JOBTYPE = 'printable_pdf'; // indexes some key/count buckets by the "key" property -const getKeyCount = (buckets: KeyCountBucket[]): { [key: string]: number } => - buckets.reduce((accum, { key, doc_count: count }) => ({ ...accum, [key]: count }), {}); +const getKeyCount = <BucketType>(buckets: KeyCountBucket[]): BucketType => + buckets.reduce( + (accum, { key, doc_count: count }) => ({ ...accum, [key]: count }), + {} as BucketType + ); // indexes some key/count buckets by statusType > jobType > appName: statusCount const getAppStatuses = (buckets: StatusByAppBucket[]) => @@ -58,7 +64,7 @@ const getAppStatuses = (buckets: StatusByAppBucket[]) => }; }, {}); -function getAggStats(aggs: AggregationResultBuckets): RangeStats { +function getAggStats(aggs: AggregationResultBuckets): Partial<RangeStats> { const { buckets: jobBuckets } = aggs[JOB_TYPES_KEY]; const jobTypes = jobBuckets.reduce( (accum: JobTypes, { key, doc_count: count }: { key: string; doc_count: number }) => { @@ -72,17 +78,11 @@ function getAggStats(aggs: AggregationResultBuckets): RangeStats { if (pdfJobs) { const pdfAppBuckets = get<KeyCountBucket[]>(aggs[OBJECT_TYPES_KEY], '.pdf.buckets', []); const pdfLayoutBuckets = get<KeyCountBucket[]>(aggs[LAYOUT_TYPES_KEY], '.pdf.buckets', []); - pdfJobs.app = getKeyCount(pdfAppBuckets) as { - visualization: number; - dashboard: number; - }; - pdfJobs.layout = getKeyCount(pdfLayoutBuckets) as { - print: number; - preserve_layout: number; - }; + pdfJobs.app = getKeyCount<AppCounts>(pdfAppBuckets); + pdfJobs.layout = getKeyCount<LayoutCounts>(pdfLayoutBuckets); } - const all = aggs.doc_count as number; + const all = aggs.doc_count; let statusTypes = {}; const statusBuckets = get<KeyCountBucket[]>(aggs[STATUS_TYPES_KEY], 'buckets', []); if (statusBuckets) { @@ -100,27 +100,22 @@ function getAggStats(aggs: AggregationResultBuckets): RangeStats { type SearchAggregation = SearchResponse['aggregations']['ranges']['buckets']; -type RangeStatSets = Partial< - RangeStats & { - lastDay: RangeStats; - last7Days: RangeStats; - } ->; +type RangeStatSets = Partial<RangeStats> & { + last7Days: Partial<RangeStats>; +}; -async function handleResponse(response: SearchResponse): Promise<RangeStatSets> { +async function handleResponse(response: SearchResponse): Promise<Partial<RangeStatSets>> { const buckets = get<SearchAggregation>(response, 'aggregations.ranges.buckets'); if (!buckets) { return {}; } - const { lastDay, last7Days, all } = buckets; + const { last7Days, all } = buckets; - const lastDayUsage = lastDay ? getAggStats(lastDay) : ({} as RangeStats); - const last7DaysUsage = last7Days ? getAggStats(last7Days) : ({} as RangeStats); - const allUsage = all ? getAggStats(all) : ({} as RangeStats); + const last7DaysUsage = last7Days ? getAggStats(last7Days) : {}; + const allUsage = all ? getAggStats(all) : {}; return { last7Days: last7DaysUsage, - lastDay: lastDayUsage, ...allUsage, }; } @@ -143,7 +138,6 @@ export async function getReportingUsage( filters: { filters: { all: { match_all: {} }, - lastDay: { range: { created_at: { gte: 'now-1d/d' } } }, last7Days: { range: { created_at: { gte: 'now-7d/d' } } }, }, }, @@ -177,25 +171,26 @@ export async function getReportingUsage( return callCluster('search', params) .then((response: SearchResponse) => handleResponse(response)) - .then((usage: RangeStatSets) => { - // Allow this to explicitly throw an exception if/when this config is deprecated, - // because we shouldn't collect browserType in that case! - const browserType = config.get('capture', 'browser', 'type'); - - const exportTypesHandler = getExportTypesHandler(exportTypesRegistry); - const availability = exportTypesHandler.getAvailability( - xpackMainInfo - ) as FeatureAvailabilityMap; - - const { lastDay, last7Days, ...all } = usage; - - return { - available: true, - browser_type: browserType, - enabled: true, - lastDay: decorateRangeStats(lastDay, availability), - last7Days: decorateRangeStats(last7Days, availability), - ...decorateRangeStats(all, availability), - }; - }); + .then( + (usage: Partial<RangeStatSets>): ReportingUsageType => { + // Allow this to explicitly throw an exception if/when this config is deprecated, + // because we shouldn't collect browserType in that case! + const browserType = config.get('capture', 'browser', 'type'); + + const exportTypesHandler = getExportTypesHandler(exportTypesRegistry); + const availability = exportTypesHandler.getAvailability( + xpackMainInfo + ) as FeatureAvailabilityMap; + + const { last7Days, ...all } = usage; + + return { + available: true, + browser_type: browserType, + enabled: true, + last7Days: decorateRangeStats(last7Days, availability), + ...decorateRangeStats(all, availability), + }; + } + ); } diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts index 61b736a3e4d8c..8509eb8b5c47a 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.ts @@ -12,7 +12,7 @@ import { getReportingUsageCollector, } from './reporting_usage_collector'; import { ReportingConfig } from '../types'; -import { SearchResponse } from './types'; +import { SearchResponse, ReportingUsageType } from './types'; const exportTypesRegistry = getExportTypesRegistry(); @@ -345,6 +345,111 @@ describe('data modeling', () => { const usageStats = await fetch(callClusterMock as any); expect(usageStats).toMatchSnapshot(); }); + + test('Cast various example data to the TypeScript definition', () => { + const check = (obj: ReportingUsageType) => { + return typeof obj; + }; + + // just check that the example objects can be cast to ReportingUsageType + check({ + PNG: { available: true, total: 7 }, + _all: 21, + available: true, + browser_type: 'chromium', + csv: { available: true, total: 4 }, + enabled: true, + last7Days: { + PNG: { available: true, total: 0 }, + _all: 0, + csv: { available: true, total: 0 }, + printable_pdf: { + app: { dashboard: 0, visualization: 0 }, + available: true, + layout: { preserve_layout: 0, print: 0 }, + total: 0, + }, + status: { completed: 0, failed: 0 }, + statuses: {}, + }, + printable_pdf: { + app: { 'canvas workpad': 3, dashboard: 3, visualization: 4 }, + available: true, + layout: { preserve_layout: 7, print: 3 }, + total: 10, + }, + status: { completed: 21, failed: 0 }, + statuses: { + completed: { + PNG: { dashboard: 3, visualization: 4 }, + csv: {}, + printable_pdf: { 'canvas workpad': 3, dashboard: 3, visualization: 4 }, + }, + }, + }); + check({ + PNG: { available: true, total: 3 }, + _all: 4, + available: true, + browser_type: 'chromium', + csv: { available: true, total: 0 }, + enabled: true, + last7Days: { + PNG: { available: true, total: 3 }, + _all: 4, + csv: { available: true, total: 0 }, + printable_pdf: { + app: { 'canvas workpad': 1, dashboard: 0, visualization: 0 }, + available: true, + layout: { preserve_layout: 1, print: 0 }, + total: 1, + }, + status: { completed: 4, failed: 0 }, + statuses: { + completed: { PNG: { visualization: 3 }, printable_pdf: { 'canvas workpad': 1 } }, + }, + }, + printable_pdf: { + app: { 'canvas workpad': 1, dashboard: 0, visualization: 0 }, + available: true, + layout: { preserve_layout: 1, print: 0 }, + total: 1, + }, + status: { completed: 4, failed: 0 }, + statuses: { + completed: { PNG: { visualization: 3 }, printable_pdf: { 'canvas workpad': 1 } }, + }, + }); + check({ + available: true, + browser_type: 'chromium', + enabled: true, + last7Days: { + _all: 0, + status: { completed: 0, failed: 0 }, + statuses: {}, + printable_pdf: { + available: true, + total: 0, + app: { dashboard: 0, visualization: 0 }, + layout: { preserve_layout: 0, print: 0 }, + }, + csv: { available: true, total: 0 }, + PNG: { available: true, total: 0 }, + }, + _all: 0, + status: { completed: 0, failed: 0 }, + statuses: {}, + printable_pdf: { + available: true, + total: 0, + app: { dashboard: 0, visualization: 0 }, + layout: { preserve_layout: 0, print: 0 }, + }, + csv: { available: true, total: 0 }, + PNG: { available: true, total: 0 }, + }); + }); }); describe('Ready for collection observable', () => { diff --git a/x-pack/legacy/plugins/reporting/server/usage/types.d.ts b/x-pack/legacy/plugins/reporting/server/usage/types.d.ts index 83f1701863355..4d7a1a33239b2 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/types.d.ts @@ -48,7 +48,6 @@ export interface SearchResponse { buckets: { all: AggregationResultBuckets; last7Days: AggregationResultBuckets; - lastDay: AggregationResultBuckets; }; }; }; @@ -59,31 +58,39 @@ export interface AvailableTotal { total: number; } -type BaseJobTypeKeys = 'csv' | 'PNG'; -export type JobTypes = { [K in BaseJobTypeKeys]: AvailableTotal } & { +type BaseJobTypes = 'csv' | 'PNG' | 'printable_pdf'; +export interface LayoutCounts { + print: number; + preserve_layout: number; +} + +type AppNames = 'canvas workpad' | 'dashboard' | 'visualization'; +export type AppCounts = { + [A in AppNames]?: number; +}; + +export type JobTypes = { [K in BaseJobTypes]: AvailableTotal } & { printable_pdf: AvailableTotal & { - app: { - visualization: number; - dashboard: number; - }; - layout: { - print: number; - preserve_layout: number; - }; + app: AppCounts; + layout: LayoutCounts; }; }; -interface StatusCounts { - [statusType: string]: number; -} - -interface StatusByAppCounts { - [statusType: string]: { - [jobType: string]: { - [appName: string]: number; - }; +type Statuses = + | 'cancelled' + | 'completed' + | 'completed_with_warnings' + | 'failed' + | 'pending' + | 'processing'; +type StatusCounts = { + [S in Statuses]?: number; +}; +type StatusByAppCounts = { + [S in Statuses]?: { + [J in BaseJobTypes]?: AppCounts; }; -} +}; export type RangeStats = JobTypes & { _all: number; @@ -91,5 +98,12 @@ export type RangeStats = JobTypes & { statuses: StatusByAppCounts; }; +export type ReportingUsageType = RangeStats & { + available: boolean; + browser_type: string; + enabled: boolean; + last7Days: RangeStats; +}; + export type ExportType = 'csv' | 'printable_pdf' | 'PNG'; export type FeatureAvailabilityMap = { [F in ExportType]: boolean }; diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 1be10f6a2056f..aafe17d970187 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -17,6 +17,7 @@ interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock<Promise<any>, any[]>; waitForSelector: jest.Mock<Promise<any>, any[]>; screenshot: jest.Mock<Promise<any>, any[]>; + open: jest.Mock<Promise<any>, any[]>; getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock<any, any>; } @@ -87,6 +88,7 @@ const defaultOpts: CreateMockBrowserDriverFactoryOpts = { evaluate: mockBrowserEvaluate, waitForSelector: mockWaitForSelector, screenshot: mockScreenshot, + open: jest.fn(), getCreatePage, }; @@ -124,6 +126,7 @@ export const createMockBrowserDriverFactory = async ( mockBrowserDriver.waitForSelector = opts.waitForSelector ? opts.waitForSelector : defaultOpts.waitForSelector; // prettier-ignore mockBrowserDriver.evaluate = opts.evaluate ? opts.evaluate : defaultOpts.evaluate; mockBrowserDriver.screenshot = opts.screenshot ? opts.screenshot : defaultOpts.screenshot; + mockBrowserDriver.open = opts.open ? opts.open : defaultOpts.open; mockBrowserDriverFactory.createPage = opts.getCreatePage ? opts.getCreatePage(mockBrowserDriver) diff --git a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js index c830fc9fcd483..99071a2f85e13 100644 --- a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js +++ b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js @@ -6,7 +6,7 @@ import { boomify } from 'boom'; import { get } from 'lodash'; -import { KIBANA_SETTINGS_TYPE } from '../../../../../monitoring/common/constants'; +import { KIBANA_SETTINGS_TYPE } from '../../../../../../../plugins/monitoring/common/constants'; const getClusterUuid = async callCluster => { const { cluster_uuid: uuid } = await callCluster('info', { filterPath: 'cluster_uuid' }); diff --git a/x-pack/package.json b/x-pack/package.json index dcc9b8c61cb96..5d1fbaa5784e0 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/actions/README.md b/x-pack/plugins/actions/README.md index decd170ca5dd6..4c8cc3aa503e6 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -28,7 +28,7 @@ Table of Contents - [RESTful API](#restful-api) - [`POST /api/action`: Create action](#post-apiaction-create-action) - [`DELETE /api/action/{id}`: Delete action](#delete-apiactionid-delete-action) - - [`GET /api/action/_getAll`: Get all actions](#get-apiaction-get-all-actions) + - [`GET /api/action/_getAll`: Get all actions](#get-apiactiongetall-get-all-actions) - [`GET /api/action/{id}`: Get action](#get-apiactionid-get-action) - [`GET /api/action/types`: List action types](#get-apiactiontypes-list-action-types) - [`PUT /api/action/{id}`: Update action](#put-apiactionid-update-action) @@ -64,6 +64,12 @@ Table of Contents - [`config`](#config-6) - [`secrets`](#secrets-6) - [`params`](#params-6) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice) + - [Jira](#jira) + - [`config`](#config-7) + - [`secrets`](#secrets-7) + - [`params`](#params-7) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) - [Command Line Utility](#command-line-utility) ## Terminology @@ -143,8 +149,8 @@ This is the primary function for an action type. Whenever the action needs to ex | actionId | The action saved object id that the action type is executing for. | | config | The decrypted configuration given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. | | params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. | -| services.callCluster(path, opts) | Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana but runs in the context of the user who is calling the action when security is enabled.| -| services.getScopedCallCluster | This function scopes an instance of CallCluster by returning a `callCluster(path, opts)` function that runs in the context of the user who is calling the action when security is enabled. This must only be called with instances of CallCluster provided by core.| +| services.callCluster(path, opts) | Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana but runs in the context of the user who is calling the action when security is enabled. | +| services.getScopedCallCluster | This function scopes an instance of CallCluster by returning a `callCluster(path, opts)` function that runs in the context of the user who is calling the action when security is enabled. This must only be called with instances of CallCluster provided by core. | | services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.<br><br>The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | | services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) | @@ -483,13 +489,59 @@ The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/a ### `params` +| Property | Description | Type | +| --------------- | ------------------------------------------------------------------------------------ | ------ | +| subAction | The sub action to perform. It can be `pushToService`, `handshake`, and `getIncident` | string | +| subActionParams | The parameters of the sub action | object | + +#### `subActionParams (pushToService)` + | Property | Description | Type | | ----------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------- | | caseId | The case id | string | | title | The title of the case | string _(optional)_ | | description | The description of the case | string _(optional)_ | | comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | -| incidentID | The id of the incident in ServiceNow . If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | +| externalId | The id of the incident in ServiceNow . If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | + +--- + +## Jira + +ID: `.jira` + +The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/platform/rest/v2/) to create and update Jira incidents. + +### `config` + +| Property | Description | Type | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| apiUrl | ServiceNow instance URL. | string | +| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object | + +### `secrets` + +| Property | Description | Type | +| -------- | --------------------------------------- | ------ | +| email | email for HTTP Basic authentication | string | +| apiToken | API token for HTTP Basic authentication | string | + +### `params` + +| Property | Description | Type | +| --------------- | ------------------------------------------------------------------------------------ | ------ | +| subAction | The sub action to perform. It can be `pushToService`, `handshake`, and `getIncident` | string | +| subActionParams | The parameters of the sub action | object | + +#### `subActionParams (pushToService)` + +| Property | Description | Type | +| ----------- | ------------------------------------------------------------------------------------------------------------------- | --------------------- | +| caseId | The case id | string | +| title | The title of the case | string _(optional)_ | +| description | The description of the case | string _(optional)_ | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | +| externalId | The id of the incident in Jira. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | # Command Line Utility diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/api.ts b/x-pack/plugins/actions/server/builtin_action_types/case/api.ts new file mode 100644 index 0000000000000..6dc8a9cc9af6a --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/api.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ExternalServiceApi, + ExternalServiceParams, + PushToServiceResponse, + GetIncidentApiHandlerArgs, + HandshakeApiHandlerArgs, + PushToServiceApiHandlerArgs, +} from './types'; +import { prepareFieldsForTransformation, transformFields, transformComments } from './utils'; + +const handshakeHandler = async ({ + externalService, + mapping, + params, +}: HandshakeApiHandlerArgs) => {}; +const getIncidentHandler = async ({ + externalService, + mapping, + params, +}: GetIncidentApiHandlerArgs) => {}; + +const pushToServiceHandler = async ({ + externalService, + mapping, + params, +}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => { + const { externalId, comments } = params; + const updateIncident = externalId ? true : false; + const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated']; + let currentIncident: ExternalServiceParams | undefined; + let res: PushToServiceResponse; + + if (externalId) { + currentIncident = await externalService.getIncident(externalId); + } + + const fields = prepareFieldsForTransformation({ + params, + mapping, + defaultPipes, + }); + + const incident = transformFields({ + params, + fields, + currentIncident, + }); + + if (updateIncident) { + res = await externalService.updateIncident({ incidentId: externalId, incident }); + } else { + res = await externalService.createIncident({ incident }); + } + + if ( + comments && + Array.isArray(comments) && + comments.length > 0 && + mapping.get('comments')?.actionType !== 'nothing' + ) { + const commentsTransformed = transformComments(comments, ['informationAdded']); + + res.comments = []; + for (const currentComment of commentsTransformed) { + const comment = await externalService.createComment({ + incidentId: res.id, + comment: currentComment, + field: mapping.get('comments')?.target ?? 'comments', + }); + res.comments = [ + ...(res.comments ?? []), + { + commentId: comment.commentId, + pushedDate: comment.pushedDate, + }, + ]; + } + } + + return res; +}; + +export const api: ExternalServiceApi = { + handshake: handshakeHandler, + pushToService: pushToServiceHandler, + getIncident: getIncidentHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/case/constants.ts new file mode 100644 index 0000000000000..1f2bc7f5e8e53 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/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 SUPPORTED_SOURCE_FIELDS = ['title', 'comments', 'description']; diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts new file mode 100644 index 0000000000000..33b2ad6d18684 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/schema.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 { schema } from '@kbn/config-schema'; + +export const MappingActionType = schema.oneOf([ + schema.literal('nothing'), + schema.literal('overwrite'), + schema.literal('append'), +]); + +export const MapRecordSchema = schema.object({ + source: schema.string(), + target: schema.string(), + actionType: MappingActionType, +}); + +export const CaseConfigurationSchema = schema.object({ + mapping: schema.arrayOf(MapRecordSchema), +}); + +export const ExternalIncidentServiceConfiguration = { + apiUrl: schema.string(), + casesConfiguration: CaseConfigurationSchema, +}; + +export const ExternalIncidentServiceConfigurationSchema = schema.object( + ExternalIncidentServiceConfiguration +); + +export const ExternalIncidentServiceSecretConfiguration = { + password: schema.string(), + username: schema.string(), +}; + +export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( + ExternalIncidentServiceSecretConfiguration +); + +export const UserSchema = schema.object({ + fullName: schema.nullable(schema.string()), + username: schema.nullable(schema.string()), +}); + +const EntityInformation = { + createdAt: schema.string(), + createdBy: UserSchema, + updatedAt: schema.nullable(schema.string()), + updatedBy: schema.nullable(UserSchema), +}; + +export const EntityInformationSchema = schema.object(EntityInformation); + +export const CommentSchema = schema.object({ + commentId: schema.string(), + comment: schema.string(), + ...EntityInformation, +}); + +export const ExecutorSubActionSchema = schema.oneOf([ + schema.literal('getIncident'), + schema.literal('pushToService'), + schema.literal('handshake'), +]); + +export const ExecutorSubActionPushParamsSchema = schema.object({ + caseId: schema.string(), + title: schema.string(), + description: schema.nullable(schema.string()), + comments: schema.nullable(schema.arrayOf(CommentSchema)), + externalId: schema.nullable(schema.string()), + ...EntityInformation, +}); + +export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ + externalId: schema.string(), +}); + +// Reserved for future implementation +export const ExecutorSubActionHandshakeParamsSchema = schema.object({}); + +export const ExecutorParamsSchema = schema.oneOf([ + schema.object({ + subAction: schema.literal('getIncident'), + subActionParams: ExecutorSubActionGetIncidentParamsSchema, + }), + schema.object({ + subAction: schema.literal('handshake'), + subActionParams: ExecutorSubActionHandshakeParamsSchema, + }), + schema.object({ + subAction: schema.literal('pushToService'), + subActionParams: ExecutorSubActionPushParamsSchema, + }), +]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/transformers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/case/transformers.test.ts new file mode 100644 index 0000000000000..75dcab65ee9f2 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/transformers.test.ts @@ -0,0 +1,131 @@ +/* + * 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 { transformers } from './transformers'; + +const { informationCreated, informationUpdated, informationAdded, append } = transformers; + +describe('informationCreated', () => { + test('transforms correctly', () => { + const res = informationCreated({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + }); + expect(res).toEqual({ value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)' }); + }); + + test('transforms correctly without optional fields', () => { + const res = informationCreated({ + value: 'a value', + }); + expect(res).toEqual({ value: 'a value (created at by )' }); + }); + + test('returns correctly rest fields', () => { + const res = informationCreated({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + previousValue: 'previous value', + }); + expect(res).toEqual({ + value: 'a value (created at 2020-04-15T08:19:27.400Z by elastic)', + previousValue: 'previous value', + }); + }); +}); + +describe('informationUpdated', () => { + test('transforms correctly', () => { + const res = informationUpdated({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + }); + expect(res).toEqual({ value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)' }); + }); + + test('transforms correctly without optional fields', () => { + const res = informationUpdated({ + value: 'a value', + }); + expect(res).toEqual({ value: 'a value (updated at by )' }); + }); + + test('returns correctly rest fields', () => { + const res = informationUpdated({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + previousValue: 'previous value', + }); + expect(res).toEqual({ + value: 'a value (updated at 2020-04-15T08:19:27.400Z by elastic)', + previousValue: 'previous value', + }); + }); +}); + +describe('informationAdded', () => { + test('transforms correctly', () => { + const res = informationAdded({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + }); + expect(res).toEqual({ value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)' }); + }); + + test('transforms correctly without optional fields', () => { + const res = informationAdded({ + value: 'a value', + }); + expect(res).toEqual({ value: 'a value (added at by )' }); + }); + + test('returns correctly rest fields', () => { + const res = informationAdded({ + value: 'a value', + date: '2020-04-15T08:19:27.400Z', + user: 'elastic', + previousValue: 'previous value', + }); + expect(res).toEqual({ + value: 'a value (added at 2020-04-15T08:19:27.400Z by elastic)', + previousValue: 'previous value', + }); + }); +}); + +describe('append', () => { + test('transforms correctly', () => { + const res = append({ + value: 'a value', + previousValue: 'previous value', + }); + expect(res).toEqual({ value: 'previous value \r\na value' }); + }); + + test('transforms correctly without optional fields', () => { + const res = append({ + value: 'a value', + }); + expect(res).toEqual({ value: 'a value' }); + }); + + test('returns correctly rest fields', () => { + const res = append({ + value: 'a value', + user: 'elastic', + previousValue: 'previous value', + }); + expect(res).toEqual({ + value: 'previous value \r\na value', + user: 'elastic', + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/transformers.ts b/x-pack/plugins/actions/server/builtin_action_types/case/transformers.ts new file mode 100644 index 0000000000000..3dca1fd703430 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/transformers.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 { TransformerArgs } from './types'; +import * as i18n from './translations'; + +export type Transformer = (args: TransformerArgs) => TransformerArgs; + +export const transformers: Record<string, Transformer> = { + informationCreated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ + value: `${value} ${i18n.FIELD_INFORMATION('create', date, user)}`, + ...rest, + }), + informationUpdated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ + value: `${value} ${i18n.FIELD_INFORMATION('update', date, user)}`, + ...rest, + }), + informationAdded: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ + value: `${value} ${i18n.FIELD_INFORMATION('add', date, user)}`, + ...rest, + }), + append: ({ value, previousValue, ...rest }: TransformerArgs): TransformerArgs => ({ + value: previousValue ? `${previousValue} \r\n${value}` : `${value}`, + ...rest, + }), +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/case/translations.ts new file mode 100644 index 0000000000000..4842728b0e4e7 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/translations.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 { i18n } from '@kbn/i18n'; + +export const API_URL_REQUIRED = i18n.translate('xpack.actions.builtin.case.connectorApiNullError', { + defaultMessage: 'connector [apiUrl] is required', +}); + +export const FIELD_INFORMATION = ( + mode: string, + date: string | undefined, + user: string | undefined +) => { + switch (mode) { + case 'create': + return i18n.translate('xpack.actions.builtin.case.common.externalIncidentCreated', { + values: { date, user }, + defaultMessage: '(created at {date} by {user})', + }); + case 'update': + return i18n.translate('xpack.actions.builtin.case.common.externalIncidentUpdated', { + values: { date, user }, + defaultMessage: '(updated at {date} by {user})', + }); + case 'add': + return i18n.translate('xpack.actions.builtin.case.common.externalIncidentAdded', { + values: { date, user }, + defaultMessage: '(added at {date} by {user})', + }); + default: + return i18n.translate('xpack.actions.builtin.case.common.externalIncidentDefault', { + values: { date, user }, + defaultMessage: '(created at {date} by {user})', + }); + } +}; + +export const MAPPING_EMPTY = i18n.translate( + 'xpack.actions.builtin.case.configuration.emptyMapping', + { + defaultMessage: '[casesConfiguration.mapping]: expected non-empty but got empty', + } +); + +export const WHITE_LISTED_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.case.configuration.apiWhitelistError', { + defaultMessage: 'error configuring connector action: {message}', + values: { + message, + }, + }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/types.ts b/x-pack/plugins/actions/server/builtin_action_types/case/types.ts new file mode 100644 index 0000000000000..459e9d2b03f92 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/types.ts @@ -0,0 +1,161 @@ +/* + * 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. + */ + +// This will have to remain `any` until we can extend connectors with generics +// and circular dependencies eliminated. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { TypeOf } from '@kbn/config-schema'; + +import { + ExternalIncidentServiceConfigurationSchema, + ExternalIncidentServiceSecretConfigurationSchema, + ExecutorParamsSchema, + CaseConfigurationSchema, + MapRecordSchema, + CommentSchema, + ExecutorSubActionPushParamsSchema, + ExecutorSubActionGetIncidentParamsSchema, + ExecutorSubActionHandshakeParamsSchema, +} from './schema'; + +export interface AnyParams { + [index: string]: string | number | object | undefined | null; +} + +export type ExternalIncidentServiceConfiguration = TypeOf< + typeof ExternalIncidentServiceConfigurationSchema +>; +export type ExternalIncidentServiceSecretConfiguration = TypeOf< + typeof ExternalIncidentServiceSecretConfigurationSchema +>; + +export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>; +export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>; + +export type ExecutorSubActionGetIncidentParams = TypeOf< + typeof ExecutorSubActionGetIncidentParamsSchema +>; + +export type ExecutorSubActionHandshakeParams = TypeOf< + typeof ExecutorSubActionHandshakeParamsSchema +>; + +export type CaseConfiguration = TypeOf<typeof CaseConfigurationSchema>; +export type MapRecord = TypeOf<typeof MapRecordSchema>; +export type Comment = TypeOf<typeof CommentSchema>; + +export interface ExternalServiceConfiguration { + id: string; + name: string; +} + +export interface ExternalServiceCredentials { + config: Record<string, any>; + secrets: Record<string, any>; +} + +export interface ExternalServiceValidation { + config: (configurationUtilities: any, configObject: any) => void; + secrets: (configurationUtilities: any, secrets: any) => void; +} + +export interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} + +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} + +export interface ExternalServiceParams { + [index: string]: any; +} + +export interface ExternalService { + getIncident: (id: string) => Promise<any>; + createIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>; + updateIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>; + createComment: (params: ExternalServiceParams) => Promise<ExternalServiceCommentResponse>; +} + +export interface PushToServiceApiParams extends ExecutorSubActionPushParams { + externalCase: Record<string, any>; +} + +export interface ExternalServiceApiHandlerArgs { + externalService: ExternalService; + mapping: Map<string, any>; +} + +export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: PushToServiceApiParams; +} + +export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionGetIncidentParams; +} + +export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionHandshakeParams; +} + +export interface PushToServiceResponse extends ExternalServiceIncidentResponse { + comments?: ExternalServiceCommentResponse[]; +} + +export interface ExternalServiceApi { + handshake: (args: HandshakeApiHandlerArgs) => Promise<void>; + pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>; + getIncident: (args: GetIncidentApiHandlerArgs) => Promise<void>; +} + +export interface CreateExternalServiceBasicArgs { + api: ExternalServiceApi; + createExternalService: (credentials: ExternalServiceCredentials) => ExternalService; +} + +export interface CreateExternalServiceArgs extends CreateExternalServiceBasicArgs { + config: ExternalServiceConfiguration; + validate: ExternalServiceValidation; + validationSchema: { config: any; secrets: any }; +} + +export interface CreateActionTypeArgs { + configurationUtilities: any; + executor?: any; +} + +export interface PipedField { + key: string; + value: string; + actionType: string; + pipes: string[]; +} + +export interface PrepareFieldsForTransformArgs { + params: PushToServiceApiParams; + mapping: Map<string, MapRecord>; + defaultPipes?: string[]; +} + +export interface TransformFieldsArgs { + params: PushToServiceApiParams; + fields: PipedField[]; + currentIncident?: ExternalServiceParams; +} + +export interface TransformerArgs { + value: string; + date?: string; + user?: string; + previousValue?: string; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts new file mode 100644 index 0000000000000..1e8cc3eda20e5 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts @@ -0,0 +1,576 @@ +/* + * 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 axios from 'axios'; + +import { + normalizeMapping, + buildMap, + mapParams, + prepareFieldsForTransformation, + transformFields, + transformComments, + addTimeZoneToDate, + throwIfNotAlive, + request, + patch, + getErrorMessage, +} from './utils'; + +import { SUPPORTED_SOURCE_FIELDS } from './constants'; +import { Comment, MapRecord, PushToServiceApiParams } from './types'; + +jest.mock('axios'); +const axiosMock = (axios as unknown) as jest.Mock; + +const mapping: MapRecord[] = [ + { source: 'title', target: 'short_description', actionType: 'overwrite' }, + { source: 'description', target: 'description', actionType: 'append' }, + { source: 'comments', target: 'comments', actionType: 'append' }, +]; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const finalMapping: Map<string, any> = new Map(); + +finalMapping.set('title', { + target: 'short_description', + actionType: 'overwrite', +}); + +finalMapping.set('description', { + target: 'description', + actionType: 'append', +}); + +finalMapping.set('comments', { + target: 'comments', + actionType: 'append', +}); + +finalMapping.set('short_description', { + target: 'title', + actionType: 'overwrite', +}); + +const maliciousMapping: MapRecord[] = [ + { source: '__proto__', target: 'short_description', actionType: 'nothing' }, + { source: 'description', target: '__proto__', actionType: 'nothing' }, + { source: 'comments', target: 'comments', actionType: 'nothing' }, + { source: 'unsupportedSource', target: 'comments', actionType: 'nothing' }, +]; + +const fullParams: PushToServiceApiParams = { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + title: 'a title', + description: 'a description', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + externalId: null, + externalCase: { + short_description: 'a title', + description: 'a description', + }, + comments: [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'second comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ], +}; + +describe('normalizeMapping', () => { + test('remove malicious fields', () => { + const sanitizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); + expect(sanitizedMapping.every(m => m.source !== '__proto__' && m.target !== '__proto__')).toBe( + true + ); + }); + + test('remove unsuppported source fields', () => { + const normalizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); + expect(normalizedMapping).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: 'unsupportedSource', + target: 'comments', + actionType: 'nothing', + }), + ]) + ); + }); +}); + +describe('buildMap', () => { + test('builds sanitized Map', () => { + const finalMap = buildMap(maliciousMapping); + expect(finalMap.get('__proto__')).not.toBeDefined(); + }); + + test('builds Map correct', () => { + const final = buildMap(mapping); + expect(final).toEqual(finalMapping); + }); +}); + +describe('mapParams', () => { + test('maps params correctly', () => { + const params = { + caseId: '123', + incidentId: '456', + title: 'Incident title', + description: 'Incident description', + }; + + const fields = mapParams(params, finalMapping); + + expect(fields).toEqual({ + short_description: 'Incident title', + description: 'Incident description', + }); + }); + + test('do not add fields not in mapping', () => { + const params = { + caseId: '123', + incidentId: '456', + title: 'Incident title', + description: 'Incident description', + }; + const fields = mapParams(params, finalMapping); + + const { title, description, ...unexpectedFields } = params; + + expect(fields).not.toEqual(expect.objectContaining(unexpectedFields)); + }); +}); + +describe('prepareFieldsForTransformation', () => { + test('prepare fields with defaults', () => { + const res = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + }); + expect(res).toEqual([ + { + key: 'short_description', + value: 'a title', + actionType: 'overwrite', + pipes: ['informationCreated'], + }, + { + key: 'description', + value: 'a description', + actionType: 'append', + pipes: ['informationCreated', 'append'], + }, + ]); + }); + + test('prepare fields with default pipes', () => { + const res = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + defaultPipes: ['myTestPipe'], + }); + expect(res).toEqual([ + { + key: 'short_description', + value: 'a title', + actionType: 'overwrite', + pipes: ['myTestPipe'], + }, + { + key: 'description', + value: 'a description', + actionType: 'append', + pipes: ['myTestPipe', 'append'], + }, + ]); + }); +}); + +describe('transformFields', () => { + test('transform fields for creation correctly', () => { + const fields = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + }); + + const res = transformFields({ + params: fullParams, + fields, + }); + + expect(res).toEqual({ + short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + }); + }); + + test('transform fields for update correctly', () => { + const fields = prepareFieldsForTransformation({ + params: { + ...fullParams, + updatedAt: '2020-03-15T08:34:53.450Z', + updatedBy: { + username: 'anotherUser', + fullName: 'Another User', + }, + }, + mapping: finalMapping, + defaultPipes: ['informationUpdated'], + }); + + const res = transformFields({ + params: { + ...fullParams, + updatedAt: '2020-03-15T08:34:53.450Z', + updatedBy: { + username: 'anotherUser', + fullName: 'Another User', + }, + }, + fields, + currentIncident: { + short_description: 'first title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + expect(res).toEqual({ + short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', + description: + 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User) \r\na description (updated at 2020-03-15T08:34:53.450Z by Another User)', + }); + }); + + test('add newline character to descripton', () => { + const fields = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + defaultPipes: ['informationUpdated'], + }); + + const res = transformFields({ + params: fullParams, + fields, + currentIncident: { + short_description: 'first title', + description: 'first description', + }, + }); + expect(res.description?.includes('\r\n')).toBe(true); + }); + + test('append username if fullname is undefined when create', () => { + const fields = prepareFieldsForTransformation({ + params: fullParams, + mapping: finalMapping, + }); + + const res = transformFields({ + params: { + ...fullParams, + createdBy: { fullName: '', username: 'elastic' }, + }, + fields, + }); + + expect(res).toEqual({ + short_description: 'a title (created at 2020-03-13T08:34:53.450Z by elastic)', + description: 'a description (created at 2020-03-13T08:34:53.450Z by elastic)', + }); + }); + + test('append username if fullname is undefined when update', () => { + const fields = prepareFieldsForTransformation({ + params: { + ...fullParams, + updatedAt: '2020-03-15T08:34:53.450Z', + updatedBy: { + username: 'anotherUser', + fullName: 'Another User', + }, + }, + mapping: finalMapping, + defaultPipes: ['informationUpdated'], + }); + + const res = transformFields({ + params: { + ...fullParams, + updatedAt: '2020-03-15T08:34:53.450Z', + updatedBy: { username: 'anotherUser', fullName: '' }, + }, + fields, + }); + + expect(res).toEqual({ + short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by anotherUser)', + description: 'a description (updated at 2020-03-15T08:34:53.450Z by anotherUser)', + }); + }); +}); + +describe('transformComments', () => { + test('transform creation comments', () => { + const comments: Comment[] = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]; + const res = transformComments(comments, ['informationCreated']); + expect(res).toEqual([ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment (created at 2020-03-13T08:34:53.450Z by Elastic User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]); + }); + + test('transform update comments', () => { + const comments: Comment[] = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-03-15T08:34:53.450Z', + updatedBy: { + fullName: 'Another User', + username: 'anotherUser', + }, + }, + ]; + const res = transformComments(comments, ['informationUpdated']); + expect(res).toEqual([ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment (updated at 2020-03-15T08:34:53.450Z by Another User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-03-15T08:34:53.450Z', + updatedBy: { + fullName: 'Another User', + username: 'anotherUser', + }, + }, + ]); + }); + + test('transform added comments', () => { + const comments: Comment[] = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]; + const res = transformComments(comments, ['informationAdded']); + expect(res).toEqual([ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]); + }); + + test('transform comments without fullname', () => { + const comments: Comment[] = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: '', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]; + const res = transformComments(comments, ['informationAdded']); + expect(res).toEqual([ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment (added at 2020-03-13T08:34:53.450Z by elastic)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: '', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ]); + }); + + test('adds update user correctly', () => { + const comments: Comment[] = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic', username: 'elastic' }, + updatedAt: '2020-04-13T08:34:53.450Z', + updatedBy: { fullName: 'Elastic2', username: 'elastic' }, + }, + ]; + const res = transformComments(comments, ['informationAdded']); + expect(res).toEqual([ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment (added at 2020-04-13T08:34:53.450Z by Elastic2)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic', username: 'elastic' }, + updatedAt: '2020-04-13T08:34:53.450Z', + updatedBy: { fullName: 'Elastic2', username: 'elastic' }, + }, + ]); + }); + + test('adds update user with empty fullname correctly', () => { + const comments: Comment[] = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic', username: 'elastic' }, + updatedAt: '2020-04-13T08:34:53.450Z', + updatedBy: { fullName: '', username: 'elastic2' }, + }, + ]; + const res = transformComments(comments, ['informationAdded']); + expect(res).toEqual([ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + comment: 'first comment (added at 2020-04-13T08:34:53.450Z by elastic2)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic', username: 'elastic' }, + updatedAt: '2020-04-13T08:34:53.450Z', + updatedBy: { fullName: '', username: 'elastic2' }, + }, + ]); + }); +}); + +describe('addTimeZoneToDate', () => { + test('adds timezone with default', () => { + const date = addTimeZoneToDate('2020-04-14T15:01:55.456Z'); + expect(date).toBe('2020-04-14T15:01:55.456Z GMT'); + }); + + test('adds timezone correctly', () => { + const date = addTimeZoneToDate('2020-04-14T15:01:55.456Z', 'PST'); + expect(date).toBe('2020-04-14T15:01:55.456Z PST'); + }); +}); + +describe('throwIfNotAlive ', () => { + test('throws correctly when status is invalid', async () => { + expect(() => { + throwIfNotAlive(404, 'application/json'); + }).toThrow('Instance is not alive.'); + }); + + test('throws correctly when content is invalid', () => { + expect(() => { + throwIfNotAlive(200, 'application/html'); + }).toThrow('Instance is not alive.'); + }); + + test('do NOT throws with custom validStatusCodes', async () => { + expect(() => { + throwIfNotAlive(404, 'application/json', [404]); + }).not.toThrow('Instance is not alive.'); + }); +}); + +describe('request', () => { + beforeEach(() => { + axiosMock.mockImplementation(() => ({ + status: 200, + headers: { 'content-type': 'application/json' }, + data: { incidentId: '123' }, + })); + }); + + test('it fetch correctly with defaults', async () => { + const res = await request({ axios, url: '/test' }); + + expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'get', data: {} }); + expect(res).toEqual({ + status: 200, + headers: { 'content-type': 'application/json' }, + data: { incidentId: '123' }, + }); + }); + + test('it fetch correctly', async () => { + const res = await request({ axios, url: '/test', method: 'post', data: { id: '123' } }); + + expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'post', data: { id: '123' } }); + expect(res).toEqual({ + status: 200, + headers: { 'content-type': 'application/json' }, + data: { incidentId: '123' }, + }); + }); + + test('it throws correctly', async () => { + axiosMock.mockImplementation(() => ({ + status: 404, + headers: { 'content-type': 'application/json' }, + data: { incidentId: '123' }, + })); + + await expect(request({ axios, url: '/test' })).rejects.toThrow(); + }); +}); + +describe('patch', () => { + beforeEach(() => { + axiosMock.mockImplementation(() => ({ + status: 200, + headers: { 'content-type': 'application/json' }, + })); + }); + + test('it fetch correctly', async () => { + await patch({ axios, url: '/test', data: { id: '123' } }); + expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'patch', data: { id: '123' } }); + }); +}); + +describe('getErrorMessage', () => { + test('it returns the correct error message', () => { + const msg = getErrorMessage('My connector name', 'An error has occurred'); + expect(msg).toBe('[Action][My connector name]: An error has occurred'); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts new file mode 100644 index 0000000000000..7d69b2791f624 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts @@ -0,0 +1,249 @@ +/* + * 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 { curry, flow, get } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { AxiosInstance, Method, AxiosResponse } from 'axios'; + +import { ActionTypeExecutorOptions, ActionTypeExecutorResult, ActionType } from '../../types'; + +import { ExecutorParamsSchema } from './schema'; + +import { + CreateExternalServiceArgs, + CreateActionTypeArgs, + ExecutorParams, + MapRecord, + AnyParams, + CreateExternalServiceBasicArgs, + PrepareFieldsForTransformArgs, + PipedField, + TransformFieldsArgs, + Comment, + ExecutorSubActionPushParams, +} from './types'; + +import { transformers, Transformer } from './transformers'; + +import { SUPPORTED_SOURCE_FIELDS } from './constants'; + +export const normalizeMapping = (supportedFields: string[], mapping: MapRecord[]): MapRecord[] => { + // Prevent prototype pollution and remove unsupported fields + return mapping.filter( + m => m.source !== '__proto__' && m.target !== '__proto__' && supportedFields.includes(m.source) + ); +}; + +export const buildMap = (mapping: MapRecord[]): Map<string, MapRecord> => { + return normalizeMapping(SUPPORTED_SOURCE_FIELDS, mapping).reduce((fieldsMap, field) => { + const { source, target, actionType } = field; + fieldsMap.set(source, { target, actionType }); + fieldsMap.set(target, { target: source, actionType }); + return fieldsMap; + }, new Map()); +}; + +export const mapParams = ( + params: Partial<ExecutorSubActionPushParams>, + mapping: Map<string, MapRecord> +): AnyParams => { + return Object.keys(params).reduce((prev: AnyParams, curr: string): AnyParams => { + const field = mapping.get(curr); + if (field) { + prev[field.target] = get(params, curr); + } + return prev; + }, {}); +}; + +export const createConnectorExecutor = ({ + api, + createExternalService, +}: CreateExternalServiceBasicArgs) => async ( + execOptions: ActionTypeExecutorOptions +): Promise<ActionTypeExecutorResult> => { + const { actionId, config, params, secrets } = execOptions; + const { subAction, subActionParams } = params as ExecutorParams; + let data = {}; + + const res: Pick<ActionTypeExecutorResult, 'status'> & + Pick<ActionTypeExecutorResult, 'actionId'> = { + status: 'ok', + actionId, + }; + + const externalService = createExternalService({ + config, + secrets, + }); + + if (!api[subAction]) { + throw new Error('[Action][ExternalService] Unsupported subAction type.'); + } + + if (subAction !== 'pushToService') { + throw new Error('[Action][ExternalService] subAction not implemented.'); + } + + if (subAction === 'pushToService') { + const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; + const { comments, externalId, ...restParams } = pushToServiceParams; + + const mapping = buildMap(config.casesConfiguration.mapping); + const externalCase = mapParams(restParams, mapping); + + data = await api.pushToService({ + externalService, + mapping, + params: { ...pushToServiceParams, externalCase }, + }); + } + + return { + ...res, + data, + }; +}; + +export const createConnector = ({ + api, + config, + validate, + createExternalService, + validationSchema, +}: CreateExternalServiceArgs) => { + return ({ + configurationUtilities, + executor = createConnectorExecutor({ api, createExternalService }), + }: CreateActionTypeArgs): ActionType => ({ + id: config.id, + name: config.name, + minimumLicenseRequired: 'platinum', + validate: { + config: schema.object(validationSchema.config, { + validate: curry(validate.config)(configurationUtilities), + }), + secrets: schema.object(validationSchema.secrets, { + validate: curry(validate.secrets)(configurationUtilities), + }), + params: ExecutorParamsSchema, + }, + executor, + }); +}; + +export const throwIfNotAlive = ( + status: number, + contentType: string, + validStatusCodes: number[] = [200, 201, 204] +) => { + if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) { + throw new Error('Instance is not alive.'); + } +}; + +export const request = async <T = unknown>({ + axios, + url, + method = 'get', + data, +}: { + axios: AxiosInstance; + url: string; + method?: Method; + data?: T; +}): Promise<AxiosResponse> => { + const res = await axios(url, { method, data: data ?? {} }); + throwIfNotAlive(res.status, res.headers['content-type']); + return res; +}; + +export const patch = async <T = unknown>({ + axios, + url, + data, +}: { + axios: AxiosInstance; + url: string; + data: T; +}): Promise<AxiosResponse> => { + return request({ + axios, + url, + method: 'patch', + data, + }); +}; + +export const addTimeZoneToDate = (date: string, timezone = 'GMT'): string => { + return `${date} ${timezone}`; +}; + +export const prepareFieldsForTransformation = ({ + params, + mapping, + defaultPipes = ['informationCreated'], +}: PrepareFieldsForTransformArgs): PipedField[] => { + return Object.keys(params.externalCase) + .filter(p => mapping.get(p)?.actionType != null && mapping.get(p)?.actionType !== 'nothing') + .map(p => { + const actionType = mapping.get(p)?.actionType ?? 'nothing'; + return { + key: p, + value: params.externalCase[p], + actionType, + pipes: actionType === 'append' ? [...defaultPipes, 'append'] : defaultPipes, + }; + }); +}; + +export const transformFields = ({ + params, + fields, + currentIncident, +}: TransformFieldsArgs): Record<string, string> => { + return fields.reduce((prev, cur) => { + const transform = flow<Transformer>(...cur.pipes.map(p => transformers[p])); + return { + ...prev, + [cur.key]: transform({ + value: cur.value, + date: params.updatedAt ?? params.createdAt, + user: + (params.updatedBy != null + ? params.updatedBy.fullName + ? params.updatedBy.fullName + : params.updatedBy.username + : params.createdBy.fullName + ? params.createdBy.fullName + : params.createdBy.username) ?? '', + previousValue: currentIncident ? currentIncident[cur.key] : '', + }).value, + }; + }, {}); +}; + +export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => { + return comments.map(c => ({ + ...c, + comment: flow<Transformer>(...pipes.map(p => transformers[p]))({ + value: c.comment, + date: c.updatedAt ?? c.createdAt, + user: + (c.updatedBy != null + ? c.updatedBy.fullName + ? c.updatedBy.fullName + : c.updatedBy.username + : c.createdBy.fullName + ? c.createdBy.fullName + : c.createdBy.username) ?? '', + }).value, + })); +}; + +export const getErrorMessage = (connector: string, msg: string) => { + return `[Action][${connector}]: ${msg}`; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/case/validators.ts new file mode 100644 index 0000000000000..80e301e5be082 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/case/validators.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash'; + +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { + ExternalIncidentServiceConfiguration, + ExternalIncidentServiceSecretConfiguration, +} from './types'; + +import * as i18n from './translations'; + +export const validateCommonConfig = ( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ExternalIncidentServiceConfiguration +) => { + try { + if (isEmpty(configObject.casesConfiguration.mapping)) { + return i18n.MAPPING_EMPTY; + } + + configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); + } catch (whitelistError) { + return i18n.WHITE_LISTED_ERROR(whitelistError.message); + } +}; + +export const validateCommonSecrets = ( + configurationUtilities: ActionsConfigurationUtilities, + secrets: ExternalIncidentServiceSecretConfiguration +) => {}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index a92a279d08439..6ba4d7cfc7de0 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -12,9 +12,10 @@ import { getActionType as getEmailActionType } from './email'; import { getActionType as getIndexActionType } from './es_index'; import { getActionType as getPagerDutyActionType } from './pagerduty'; import { getActionType as getServerLogActionType } from './server_log'; -import { getActionType as getServiceNowActionType } from './servicenow'; import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; +import { getActionType as getServiceNowActionType } from './servicenow'; +import { getActionType as getJiraActionType } from './jira'; export function registerBuiltInActionTypes({ actionsConfigUtils: configurationUtilities, @@ -29,7 +30,8 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getIndexActionType({ logger })); actionTypeRegistry.register(getPagerDutyActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServerLogActionType({ logger })); - actionTypeRegistry.register(getServiceNowActionType({ configurationUtilities })); actionTypeRegistry.register(getSlackActionType({ configurationUtilities })); actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); + actionTypeRegistry.register(getServiceNowActionType({ configurationUtilities })); + actionTypeRegistry.register(getJiraActionType({ configurationUtilities })); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts new file mode 100644 index 0000000000000..bcfb82077d286 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.test.ts @@ -0,0 +1,517 @@ +/* + * 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 { api } from '../case/api'; +import { externalServiceMock, mapping, apiParams } from './mocks'; +import { ExternalService } from '../case/types'; + +describe('api', () => { + let externalService: jest.Mocked<ExternalService>; + + beforeEach(() => { + externalService = externalServiceMock.create(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('pushToService', () => { + describe('create incident', () => { + test('it creates an incident', async () => { + const params = { ...apiParams, externalId: null }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-04-27T10:59:46.202Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-04-27T10:59:46.202Z', + }, + ], + }); + }); + + test('it creates an incident without comments', async () => { + const params = { ...apiParams, externalId: null, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }); + }); + + test('it calls createIncident correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.createIncident).toHaveBeenCalledWith({ + incident: { + description: + 'Incident description (created at 2020-04-27T10:59:46.202Z by Elastic User)', + summary: 'Incident title (created at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + expect(externalService.updateIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('update incident', () => { + test('it updates an incident', async () => { + const res = await api.pushToService({ externalService, mapping, params: apiParams }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-04-27T10:59:46.202Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-04-27T10:59:46.202Z', + }, + ], + }); + }); + + test('it updates an incident without comments', async () => { + const params = { ...apiParams, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }); + }); + + test('it calls updateIncident correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + expect(externalService.createIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('mapping variations', () => { + test('overwrite & append', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + description: + 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('nothing & append', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('append & append', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: + 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + description: + 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('nothing & nothing', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: {}, + }); + }); + + test('overwrite & nothing', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('overwrite & overwrite', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + description: + 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('nothing & overwrite', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('append & overwrite', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: + 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + description: + 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('append & nothing', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: + 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('comment nothing', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'nothing', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.createComment).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/api.ts new file mode 100644 index 0000000000000..3db66e5884af4 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/api.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 { api } from '../case/api'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/config.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/config.ts new file mode 100644 index 0000000000000..7e415109f1bd9 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/config.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExternalServiceConfiguration } from '../case/types'; +import * as i18n from './translations'; + +export const config: ExternalServiceConfiguration = { + id: '.jira', + name: i18n.NAME, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts new file mode 100644 index 0000000000000..a2d7bb5930a75 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createConnector } from '../case/utils'; + +import { api } from './api'; +import { config } from './config'; +import { validate } from './validators'; +import { createExternalService } from './service'; +import { JiraSecretConfiguration, JiraPublicConfiguration } from './schema'; + +export const getActionType = createConnector({ + api, + config, + validate, + createExternalService, + validationSchema: { + config: JiraPublicConfiguration, + secrets: JiraSecretConfiguration, + }, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts new file mode 100644 index 0000000000000..3ae0e9db36de0 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts @@ -0,0 +1,124 @@ +/* + * 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 { + ExternalService, + PushToServiceApiParams, + ExecutorSubActionPushParams, + MapRecord, +} from '../case/types'; + +const createMock = (): jest.Mocked<ExternalService> => { + const service = { + getIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: 'incident-1', + key: 'CK-1', + summary: 'title from jira', + description: 'description from jira', + created: '2020-04-27T10:59:46.202Z', + updated: '2020-04-27T10:59:46.202Z', + }) + ), + createIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }) + ), + updateIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }) + ), + createComment: jest.fn(), + }; + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-1', + pushedDate: '2020-04-27T10:59:46.202Z', + externalCommentId: '1', + }) + ); + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-2', + pushedDate: '2020-04-27T10:59:46.202Z', + externalCommentId: '2', + }) + ); + + return service; +}; + +const externalServiceMock = { + create: createMock, +}; + +const mapping: Map<string, Partial<MapRecord>> = new Map(); + +mapping.set('title', { + target: 'summary', + actionType: 'overwrite', +}); + +mapping.set('description', { + target: 'description', + actionType: 'overwrite', +}); + +mapping.set('comments', { + target: 'comments', + actionType: 'append', +}); + +mapping.set('summary', { + target: 'title', + actionType: 'overwrite', +}); + +const executorParams: ExecutorSubActionPushParams = { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + externalId: 'incident-3', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + ], +}; + +const apiParams: PushToServiceApiParams = { + ...executorParams, + externalCase: { summary: 'Incident title', description: 'Incident description' }, +}; + +export { externalServiceMock, mapping, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.ts new file mode 100644 index 0000000000000..9c831e75d91c1 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/schema.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 { schema } from '@kbn/config-schema'; +import { ExternalIncidentServiceConfiguration } from '../case/schema'; + +export const JiraPublicConfiguration = { + projectKey: schema.string(), + ...ExternalIncidentServiceConfiguration, +}; + +export const JiraPublicConfigurationSchema = schema.object(JiraPublicConfiguration); + +export const JiraSecretConfiguration = { + email: schema.string(), + apiToken: schema.string(), +}; + +export const JiraSecretConfigurationSchema = schema.object(JiraSecretConfiguration); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts new file mode 100644 index 0000000000000..b9225b043d526 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -0,0 +1,297 @@ +/* + * 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 axios from 'axios'; + +import { createExternalService } from './service'; +import * as utils from '../case/utils'; +import { ExternalService } from '../case/types'; + +jest.mock('axios'); +jest.mock('../case/utils', () => { + const originalUtils = jest.requireActual('../case/utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; + +describe('Jira service', () => { + let service: ExternalService; + + beforeAll(() => { + service = createExternalService({ + config: { apiUrl: 'https://siem-kibana.atlassian.net', projectKey: 'CK' }, + secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createExternalService', () => { + test('throws without url', () => { + expect(() => + createExternalService({ + config: { apiUrl: null, projectKey: 'CK' }, + secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, + }) + ).toThrow(); + }); + + test('throws without projectKey', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', projectKey: null }, + secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, + }) + ).toThrow(); + }); + + test('throws without username', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com' }, + secrets: { apiToken: '', email: 'elastic@elastic.com' }, + }) + ).toThrow(); + }); + + test('throws without password', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com' }, + secrets: { apiToken: '', email: undefined }, + }) + ).toThrow(); + }); + }); + + describe('getIncident', () => { + test('it returns the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + })); + const res = await service.getIncident('1'); + expect(res).toEqual({ id: '1', key: 'CK-1', summary: 'title', description: 'description' }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { id: '1', key: 'CK-1' }, + })); + + await service.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + expect(service.getIncident('1')).rejects.toThrow( + 'Unable to get incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createIncident', () => { + test('it creates the incident correctly', async () => { + // The response from Jira when creating an issue contains only the key and the id. + // The service makes two calls when creating an issue. One to create and one to get + // the created incident with all the necessary fields. + requestMock.mockImplementationOnce(() => ({ + data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + })); + + requestMock.mockImplementationOnce(() => ({ + data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + })); + + const res = await service.createIncident({ + incident: { summary: 'title', description: 'desc' }, + }); + + expect(res).toEqual({ + title: 'CK-1', + id: '1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + key: 'CK-1', + fields: { created: '2020-04-27T10:59:46.202Z' }, + }, + })); + + await service.createIncident({ + incident: { summary: 'title', description: 'desc' }, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue', + method: 'post', + data: { + fields: { + summary: 'title', + description: 'desc', + project: { key: 'CK' }, + issuetype: { name: 'Task' }, + }, + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createIncident({ + incident: { summary: 'title', description: 'desc' }, + }) + ).rejects.toThrow('[Action][Jira]: Unable to create incident. Error: An error has occurred'); + }); + }); + + describe('updateIncident', () => { + test('it updates the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + key: 'CK-1', + fields: { updated: '2020-04-27T10:59:46.202Z' }, + }, + })); + + const res = await service.updateIncident({ + incidentId: '1', + incident: { summary: 'title', description: 'desc' }, + }); + + expect(res).toEqual({ + title: 'CK-1', + id: '1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + key: 'CK-1', + fields: { updated: '2020-04-27T10:59:46.202Z' }, + }, + })); + + await service.updateIncident({ + incidentId: '1', + incident: { summary: 'title', description: 'desc' }, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + method: 'put', + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', + data: { fields: { summary: 'title', description: 'desc' } }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.updateIncident({ + incidentId: '1', + incident: { summary: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][Jira]: Unable to update incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createComment', () => { + test('it creates the comment correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + key: 'CK-1', + created: '2020-04-27T10:59:46.202Z', + }, + })); + + const res = await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }); + + expect(res).toEqual({ + commentId: 'comment-1', + pushedDate: '2020-04-27T10:59:46.202Z', + externalCommentId: '1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + key: 'CK-1', + created: '2020-04-27T10:59:46.202Z', + }, + })); + + await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'my_field', + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + method: 'post', + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1/comment', + data: { body: 'comment' }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }) + ).rejects.toThrow( + '[Action][Jira]: Unable to create comment at incident with id 1. Error: An error has occurred' + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts new file mode 100644 index 0000000000000..ff22b8368e7dd --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -0,0 +1,156 @@ +/* + * 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 axios from 'axios'; + +import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types'; +import { + JiraPublicConfigurationType, + JiraSecretConfigurationType, + CreateIncidentRequest, + UpdateIncidentRequest, + CreateCommentRequest, +} from './types'; + +import * as i18n from './translations'; +import { getErrorMessage, request } from '../case/utils'; + +const VERSION = '2'; +const BASE_URL = `rest/api/${VERSION}`; +const INCIDENT_URL = `issue`; +const COMMENT_URL = `comment`; + +const VIEW_INCIDENT_URL = `browse`; + +export const createExternalService = ({ + config, + secrets, +}: ExternalServiceCredentials): ExternalService => { + const { apiUrl: url, projectKey } = config as JiraPublicConfigurationType; + const { apiToken, email } = secrets as JiraSecretConfigurationType; + + if (!url || !projectKey || !apiToken || !email) { + throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); + } + + const incidentUrl = `${url}/${BASE_URL}/${INCIDENT_URL}`; + const commentUrl = `${incidentUrl}/{issueId}/${COMMENT_URL}`; + const axiosInstance = axios.create({ + auth: { username: email, password: apiToken }, + }); + + const getIncidentViewURL = (key: string) => { + return `${url}/${VIEW_INCIDENT_URL}/${key}`; + }; + + const getCommentsURL = (issueId: string) => { + return commentUrl.replace('{issueId}', issueId); + }; + + const getIncident = async (id: string) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}/${id}`, + }); + + const { fields, ...rest } = res.data; + + return { ...rest, ...fields }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`) + ); + } + }; + + const createIncident = async ({ incident }: ExternalServiceParams) => { + // The response from Jira when creating an issue contains only the key and the id. + // The function makes two calls when creating an issue. One to create the issue and one to get + // the created issue with all the necessary fields. + try { + const res = await request<CreateIncidentRequest>({ + axios: axiosInstance, + url: `${incidentUrl}`, + method: 'post', + data: { + fields: { ...incident, project: { key: projectKey }, issuetype: { name: 'Task' } }, + }, + }); + + const updatedIncident = await getIncident(res.data.id); + + return { + title: updatedIncident.key, + id: updatedIncident.id, + pushedDate: new Date(updatedIncident.created).toISOString(), + url: getIncidentViewURL(updatedIncident.key), + }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`) + ); + } + }; + + const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + try { + await request<UpdateIncidentRequest>({ + axios: axiosInstance, + method: 'put', + url: `${incidentUrl}/${incidentId}`, + data: { fields: { ...incident } }, + }); + + const updatedIncident = await getIncident(incidentId); + + return { + title: updatedIncident.key, + id: updatedIncident.id, + pushedDate: new Date(updatedIncident.updated).toISOString(), + url: getIncidentViewURL(updatedIncident.key), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to update incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => { + try { + const res = await request<CreateCommentRequest>({ + axios: axiosInstance, + method: 'post', + url: getCommentsURL(incidentId), + data: { body: comment.comment }, + }); + + return { + commentId: comment.commentId, + externalCommentId: res.data.id, + pushedDate: new Date(res.data.created).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + return { + getIncident, + createIncident, + updateIncident, + createComment, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/translations.ts new file mode 100644 index 0000000000000..dae0d75952e11 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/translations.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 NAME = i18n.translate('xpack.actions.builtin.case.jiraTitle', { + defaultMessage: 'Jira', +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts new file mode 100644 index 0000000000000..8d9c6b92abb3b --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/types.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { JiraPublicConfigurationSchema, JiraSecretConfigurationSchema } from './schema'; + +export type JiraPublicConfigurationType = TypeOf<typeof JiraPublicConfigurationSchema>; +export type JiraSecretConfigurationType = TypeOf<typeof JiraSecretConfigurationSchema>; + +interface CreateIncidentBasicRequestArgs { + summary: string; + description: string; +} +interface CreateIncidentRequestArgs extends CreateIncidentBasicRequestArgs { + project: { key: string }; + issuetype: { name: string }; +} + +export interface CreateIncidentRequest { + fields: CreateIncidentRequestArgs; +} + +export interface UpdateIncidentRequest { + fields: Partial<CreateIncidentBasicRequestArgs>; +} + +export interface CreateCommentRequest { + body: string; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts new file mode 100644 index 0000000000000..7226071392bc6 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/validators.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateCommonConfig, validateCommonSecrets } from '../case/validators'; +import { ExternalServiceValidation } from '../case/types'; + +export const validate: ExternalServiceValidation = { + config: validateCommonConfig, + secrets: validateCommonSecrets, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts deleted file mode 100644 index aa9b1dcfcf239..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts +++ /dev/null @@ -1,850 +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 { - handleCreateIncident, - handleUpdateIncident, - handleIncident, - createComments, -} from './action_handlers'; -import { ServiceNow } from './lib'; -import { Mapping } from './types'; - -jest.mock('./lib'); - -const ServiceNowMock = ServiceNow as jest.Mock; - -const finalMapping: Mapping = new Map(); - -finalMapping.set('title', { - target: 'short_description', - actionType: 'overwrite', -}); - -finalMapping.set('description', { - target: 'description', - actionType: 'overwrite', -}); - -finalMapping.set('comments', { - target: 'comments', - actionType: 'append', -}); - -finalMapping.set('short_description', { - target: 'title', - actionType: 'overwrite', -}); - -const params = { - caseId: '123', - title: 'a title', - description: 'a description', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - incidentId: null, - incident: { - short_description: 'a title', - description: 'a description', - }, - comments: [ - { - commentId: '456', - version: 'WzU3LDFd', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ], -}; - -beforeAll(() => { - ServiceNowMock.mockImplementation(() => { - return { - serviceNow: { - getUserID: jest.fn().mockResolvedValue('1234'), - getIncident: jest.fn().mockResolvedValue({ - short_description: 'servicenow title', - description: 'servicenow desc', - }), - createIncident: jest.fn().mockResolvedValue({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }), - updateIncident: jest.fn().mockResolvedValue({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }), - batchCreateComments: jest - .fn() - .mockResolvedValue([{ commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }]), - }, - }; - }); -}); - -describe('handleIncident', () => { - test('create an incident', async () => { - const { serviceNow } = new ServiceNowMock(); - - const res = await handleIncident({ - incidentId: null, - serviceNow, - params, - comments: params.comments, - mapping: finalMapping, - }); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - comments: [ - { - commentId: '456', - pushedDate: '2020-03-10T12:24:20.000Z', - }, - ], - }); - }); - test('update an incident', async () => { - const { serviceNow } = new ServiceNowMock(); - - const res = await handleIncident({ - incidentId: '123', - serviceNow, - params, - comments: params.comments, - mapping: finalMapping, - }); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - comments: [ - { - commentId: '456', - pushedDate: '2020-03-10T12:24:20.000Z', - }, - ], - }); - }); -}); - -describe('handleCreateIncident', () => { - test('create an incident without comments', async () => { - const { serviceNow } = new ServiceNowMock(); - - const res = await handleCreateIncident({ - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.createIncident).toHaveBeenCalled(); - expect(serviceNow.createIncident).toHaveBeenCalledWith({ - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.createIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('create an incident with comments', async () => { - const { serviceNow } = new ServiceNowMock(); - - const res = await handleCreateIncident({ - serviceNow, - params, - comments: params.comments, - mapping: finalMapping, - }); - - expect(serviceNow.createIncident).toHaveBeenCalled(); - expect(serviceNow.createIncident).toHaveBeenCalledWith({ - description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.createIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).toHaveBeenCalled(); - expect(serviceNow.batchCreateComments).toHaveBeenCalledWith( - '123', - [ - { - comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - commentId: '456', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: null, - updatedBy: null, - version: 'WzU3LDFd', - }, - ], - 'comments' - ); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - comments: [ - { - commentId: '456', - pushedDate: '2020-03-10T12:24:20.000Z', - }, - ], - }); - }); -}); - -describe('handleUpdateIncident', () => { - test('update an incident without comments', async () => { - const { serviceNow } = new ServiceNowMock(); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params: { - ...params, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { fullName: 'Another User', username: 'anotherUser' }, - }, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', - description: 'a description (updated at 2020-03-15T08:34:53.450Z by Another User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('update an incident with comments', async () => { - const { serviceNow } = new ServiceNowMock(); - serviceNow.batchCreateComments.mockResolvedValue([ - { commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }, - { commentId: '789', pushedDate: '2020-03-10T12:24:20.000Z' }, - ]); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params: { - ...params, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { fullName: 'Another User', username: 'anotherUser' }, - }, - comments: [ - { - comment: 'first comment', - commentId: '456', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: null, - updatedBy: null, - version: 'WzU3LDFd', - }, - { - comment: 'second comment', - commentId: '789', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-03-16T08:34:53.450Z', - updatedBy: { - fullName: 'Another User', - username: 'anotherUser', - }, - version: 'WzU3LDFd', - }, - ], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - description: 'a description (updated at 2020-03-15T08:34:53.450Z by Another User)', - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).toHaveBeenCalled(); - expect(serviceNow.batchCreateComments).toHaveBeenCalledWith( - '123', - [ - { - comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - commentId: '456', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: null, - updatedBy: null, - version: 'WzU3LDFd', - }, - { - comment: 'second comment (added at 2020-03-16T08:34:53.450Z by Another User)', - commentId: '789', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-03-16T08:34:53.450Z', - updatedBy: { - fullName: 'Another User', - username: 'anotherUser', - }, - version: 'WzU3LDFd', - }, - ], - 'comments' - ); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - comments: [ - { - commentId: '456', - pushedDate: '2020-03-10T12:24:20.000Z', - }, - { - commentId: '789', - pushedDate: '2020-03-10T12:24:20.000Z', - }, - ], - }); - }); -}); - -describe('handleUpdateIncident: different action types', () => { - test('overwrite & append', async () => { - const { serviceNow } = new ServiceNowMock(); - finalMapping.set('title', { - target: 'short_description', - actionType: 'overwrite', - }); - - finalMapping.set('description', { - target: 'description', - actionType: 'append', - }); - - finalMapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - finalMapping.set('short_description', { - target: 'title', - actionType: 'overwrite', - }); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - description: - 'servicenow desc \r\na description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('nothing & append', async () => { - const { serviceNow } = new ServiceNowMock(); - finalMapping.set('title', { - target: 'short_description', - actionType: 'nothing', - }); - - finalMapping.set('description', { - target: 'description', - actionType: 'append', - }); - - finalMapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - finalMapping.set('short_description', { - target: 'title', - actionType: 'nothing', - }); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - description: - 'servicenow desc \r\na description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('append & append', async () => { - const { serviceNow } = new ServiceNowMock(); - finalMapping.set('title', { - target: 'short_description', - actionType: 'append', - }); - - finalMapping.set('description', { - target: 'description', - actionType: 'append', - }); - - finalMapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - finalMapping.set('short_description', { - target: 'title', - actionType: 'append', - }); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - short_description: - 'servicenow title \r\na title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - description: - 'servicenow desc \r\na description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('nothing & nothing', async () => { - const { serviceNow } = new ServiceNowMock(); - finalMapping.set('title', { - target: 'short_description', - actionType: 'nothing', - }); - - finalMapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - finalMapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - finalMapping.set('short_description', { - target: 'title', - actionType: 'nothing', - }); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', {}); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('overwrite & nothing', async () => { - const { serviceNow } = new ServiceNowMock(); - finalMapping.set('title', { - target: 'short_description', - actionType: 'overwrite', - }); - - finalMapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - finalMapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - finalMapping.set('short_description', { - target: 'title', - actionType: 'overwrite', - }); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('overwrite & overwrite', async () => { - const { serviceNow } = new ServiceNowMock(); - finalMapping.set('title', { - target: 'short_description', - actionType: 'overwrite', - }); - - finalMapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - finalMapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - finalMapping.set('short_description', { - target: 'title', - actionType: 'overwrite', - }); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('nothing & overwrite', async () => { - const { serviceNow } = new ServiceNowMock(); - finalMapping.set('title', { - target: 'short_description', - actionType: 'nothing', - }); - - finalMapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - finalMapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - finalMapping.set('short_description', { - target: 'title', - actionType: 'nothing', - }); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('append & overwrite', async () => { - const { serviceNow } = new ServiceNowMock(); - finalMapping.set('title', { - target: 'short_description', - actionType: 'append', - }); - - finalMapping.set('description', { - target: 'description', - actionType: 'overwrite', - }); - - finalMapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - finalMapping.set('short_description', { - target: 'title', - actionType: 'append', - }); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - short_description: - 'servicenow title \r\na title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('append & nothing', async () => { - const { serviceNow } = new ServiceNowMock(); - finalMapping.set('title', { - target: 'short_description', - actionType: 'append', - }); - - finalMapping.set('description', { - target: 'description', - actionType: 'nothing', - }); - - finalMapping.set('comments', { - target: 'comments', - actionType: 'append', - }); - - finalMapping.set('short_description', { - target: 'title', - actionType: 'append', - }); - - const res = await handleUpdateIncident({ - incidentId: '123', - serviceNow, - params, - comments: [], - mapping: finalMapping, - }); - - expect(serviceNow.updateIncident).toHaveBeenCalled(); - expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', { - short_description: - 'servicenow title \r\na title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - expect(serviceNow.updateIncident).toHaveReturned(); - expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); -}); - -describe('createComments', () => { - test('create comments correctly', async () => { - const { serviceNow } = new ServiceNowMock(); - serviceNow.batchCreateComments.mockResolvedValue([ - { commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }, - { commentId: '789', pushedDate: '2020-03-10T12:24:20.000Z' }, - ]); - - const comments = [ - { - comment: 'first comment', - commentId: '456', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: null, - updatedBy: null, - version: 'WzU3LDFd', - }, - { - comment: 'second comment', - commentId: '789', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - version: 'WzU3LDFd', - }, - ]; - - const res = await createComments(serviceNow, '123', 'comments', comments); - - expect(serviceNow.batchCreateComments).toHaveBeenCalled(); - expect(serviceNow.batchCreateComments).toHaveBeenCalledWith( - '123', - [ - { - comment: 'first comment', - commentId: '456', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: null, - updatedBy: null, - version: 'WzU3LDFd', - }, - { - comment: 'second comment', - commentId: '789', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { - fullName: 'Elastic User', - username: 'elastic', - }, - version: 'WzU3LDFd', - }, - ], - 'comments' - ); - expect(res).toEqual([ - { - commentId: '456', - pushedDate: '2020-03-10T12:24:20.000Z', - }, - { - commentId: '789', - pushedDate: '2020-03-10T12:24:20.000Z', - }, - ]); - }); -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts deleted file mode 100644 index 9166f53cf757e..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts +++ /dev/null @@ -1,129 +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 { zipWith } from 'lodash'; -import { CommentResponse } from './lib/types'; -import { - HandlerResponse, - Comment, - SimpleComment, - CreateHandlerArguments, - UpdateHandlerArguments, - IncidentHandlerArguments, -} from './types'; -import { ServiceNow } from './lib'; -import { transformFields, prepareFieldsForTransformation, transformComments } from './helpers'; - -export const createComments = async ( - serviceNow: ServiceNow, - incidentId: string, - key: string, - comments: Comment[] -): Promise<SimpleComment[]> => { - const createdComments = await serviceNow.batchCreateComments(incidentId, comments, key); - - return zipWith(comments, createdComments, (a: Comment, b: CommentResponse) => ({ - commentId: a.commentId, - pushedDate: b.pushedDate, - })); -}; - -export const handleCreateIncident = async ({ - serviceNow, - params, - comments, - mapping, -}: CreateHandlerArguments): Promise<HandlerResponse> => { - const fields = prepareFieldsForTransformation({ - params, - mapping, - }); - - const incident = transformFields({ - params, - fields, - }); - - const createdIncident = await serviceNow.createIncident({ - ...incident, - }); - - const res: HandlerResponse = { ...createdIncident }; - - if ( - comments && - Array.isArray(comments) && - comments.length > 0 && - mapping.get('comments')?.actionType !== 'nothing' - ) { - comments = transformComments(comments, params, ['informationAdded']); - res.comments = [ - ...(await createComments( - serviceNow, - res.incidentId, - mapping.get('comments')!.target, - comments - )), - ]; - } - - return { ...res }; -}; - -export const handleUpdateIncident = async ({ - incidentId, - serviceNow, - params, - comments, - mapping, -}: UpdateHandlerArguments): Promise<HandlerResponse> => { - const currentIncident = await serviceNow.getIncident(incidentId); - const fields = prepareFieldsForTransformation({ - params, - mapping, - defaultPipes: ['informationUpdated'], - }); - - const incident = transformFields({ - params, - fields, - currentIncident, - }); - - const updatedIncident = await serviceNow.updateIncident(incidentId, { - ...incident, - }); - - const res: HandlerResponse = { ...updatedIncident }; - - if ( - comments && - Array.isArray(comments) && - comments.length > 0 && - mapping.get('comments')?.actionType !== 'nothing' - ) { - comments = transformComments(comments, params, ['informationAdded']); - res.comments = [ - ...(await createComments(serviceNow, incidentId, mapping.get('comments')!.target, comments)), - ]; - } - - return { ...res }; -}; - -export const handleIncident = async ({ - incidentId, - serviceNow, - params, - comments, - mapping, -}: IncidentHandlerArguments): Promise<HandlerResponse> => { - if (!incidentId) { - return await handleCreateIncident({ serviceNow, params, comments, mapping }); - } else { - return await handleUpdateIncident({ incidentId, serviceNow, params, comments, mapping }); - } -}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts new file mode 100644 index 0000000000000..86a8318841271 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -0,0 +1,523 @@ +/* + * 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 { api } from '../case/api'; +import { externalServiceMock, mapping, apiParams } from './mocks'; +import { ExternalService } from '../case/types'; + +describe('api', () => { + let externalService: jest.Mocked<ExternalService>; + + beforeEach(() => { + externalService = externalServiceMock.create(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('pushToService', () => { + describe('create incident', () => { + test('it creates an incident', async () => { + const params = { ...apiParams, externalId: null }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); + + test('it creates an incident without comments', async () => { + const params = { ...apiParams, externalId: null, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('it calls createIncident correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.createIncident).toHaveBeenCalledWith({ + incident: { + description: + 'Incident description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + short_description: + 'Incident title (created at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + expect(externalService.updateIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('update incident', () => { + test('it updates an incident', async () => { + const res = await api.pushToService({ externalService, mapping, params: apiParams }); + + expect(res).toEqual({ + id: 'incident-2', + title: 'INC02', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); + + test('it updates an incident without comments', async () => { + const params = { ...apiParams, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: 'incident-2', + title: 'INC02', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }); + }); + + test('it calls updateIncident correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + short_description: + 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + expect(externalService.createIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: 'incident-2', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: 'incident-2', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('mapping variations', () => { + test('overwrite & append', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + short_description: + 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: + 'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + }); + + test('nothing & append', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + }); + + test('append & append', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + short_description: + 'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: + 'description from servicenow \r\nIncident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + }); + + test('nothing & nothing', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: {}, + }); + }); + + test('overwrite & nothing', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + short_description: + 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + }); + + test('overwrite & overwrite', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + short_description: + 'Incident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: + 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + }); + + test('nothing & overwrite', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + }); + + test('append & overwrite', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + short_description: + 'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + description: + 'Incident description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + }); + + test('append & nothing', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + short_description: + 'title from servicenow \r\nIncident title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', + }, + }); + }); + + test('comment nothing', async () => { + mapping.set('title', { + target: 'short_description', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'nothing', + }); + + mapping.set('short_description', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.createComment).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts new file mode 100644 index 0000000000000..3db66e5884af4 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.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 { api } from '../case/api'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts new file mode 100644 index 0000000000000..4ad8108c3b137 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExternalServiceConfiguration } from '../case/types'; +import * as i18n from './translations'; + +export const config: ExternalServiceConfiguration = { + id: '.servicenow', + name: i18n.NAME, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts deleted file mode 100644 index a0ffd859e14ca..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts +++ /dev/null @@ -1,8 +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. - */ - -export const ACTION_TYPE_ID = '.servicenow'; -export const SUPPORTED_SOURCE_FIELDS = ['title', 'comments', 'description']; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts deleted file mode 100644 index cbcefe6364e8f..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts +++ /dev/null @@ -1,409 +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 { - normalizeMapping, - buildMap, - mapParams, - appendField, - appendInformationToField, - prepareFieldsForTransformation, - transformFields, - transformComments, -} from './helpers'; -import { mapping, finalMapping } from './mock'; -import { SUPPORTED_SOURCE_FIELDS } from './constants'; -import { MapEntry, Params, Comment } from './types'; - -const maliciousMapping: MapEntry[] = [ - { source: '__proto__', target: 'short_description', actionType: 'nothing' }, - { source: 'description', target: '__proto__', actionType: 'nothing' }, - { source: 'comments', target: 'comments', actionType: 'nothing' }, - { source: 'unsupportedSource', target: 'comments', actionType: 'nothing' }, -]; - -const fullParams: Params = { - caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', - title: 'a title', - description: 'a description', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - incidentId: null, - incident: { - short_description: 'a title', - description: 'a description', - }, - comments: [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'second comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ], -}; - -describe('sanitizeMapping', () => { - test('remove malicious fields', () => { - const sanitizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); - expect(sanitizedMapping.every(m => m.source !== '__proto__' && m.target !== '__proto__')).toBe( - true - ); - }); - - test('remove unsuppported source fields', () => { - const normalizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); - expect(normalizedMapping).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - source: 'unsupportedSource', - target: 'comments', - actionType: 'nothing', - }), - ]) - ); - }); -}); - -describe('buildMap', () => { - test('builds sanitized Map', () => { - const finalMap = buildMap(maliciousMapping); - expect(finalMap.get('__proto__')).not.toBeDefined(); - }); - - test('builds Map correct', () => { - const final = buildMap(mapping); - expect(final).toEqual(finalMapping); - }); -}); - -describe('mapParams', () => { - test('maps params correctly', () => { - const params = { - caseId: '123', - incidentId: '456', - title: 'Incident title', - description: 'Incident description', - }; - - const fields = mapParams(params, finalMapping); - - expect(fields).toEqual({ - short_description: 'Incident title', - description: 'Incident description', - }); - }); - - test('do not add fields not in mapping', () => { - const params = { - caseId: '123', - incidentId: '456', - title: 'Incident title', - description: 'Incident description', - }; - const fields = mapParams(params, finalMapping); - - const { title, description, ...unexpectedFields } = params; - - expect(fields).not.toEqual(expect.objectContaining(unexpectedFields)); - }); -}); - -describe('prepareFieldsForTransformation', () => { - test('prepare fields with defaults', () => { - const res = prepareFieldsForTransformation({ - params: fullParams, - mapping: finalMapping, - }); - expect(res).toEqual([ - { - key: 'short_description', - value: 'a title', - actionType: 'overwrite', - pipes: ['informationCreated'], - }, - { - key: 'description', - value: 'a description', - actionType: 'append', - pipes: ['informationCreated', 'append'], - }, - ]); - }); - - test('prepare fields with default pipes', () => { - const res = prepareFieldsForTransformation({ - params: fullParams, - mapping: finalMapping, - defaultPipes: ['myTestPipe'], - }); - expect(res).toEqual([ - { - key: 'short_description', - value: 'a title', - actionType: 'overwrite', - pipes: ['myTestPipe'], - }, - { - key: 'description', - value: 'a description', - actionType: 'append', - pipes: ['myTestPipe', 'append'], - }, - ]); - }); -}); - -describe('transformFields', () => { - test('transform fields for creation correctly', () => { - const fields = prepareFieldsForTransformation({ - params: fullParams, - mapping: finalMapping, - }); - - const res = transformFields({ - params: fullParams, - fields, - }); - - expect(res).toEqual({ - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', - }); - }); - - test('transform fields for update correctly', () => { - const fields = prepareFieldsForTransformation({ - params: { - ...fullParams, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { username: 'anotherUser', fullName: 'Another User' }, - }, - mapping: finalMapping, - defaultPipes: ['informationUpdated'], - }); - - const res = transformFields({ - params: { - ...fullParams, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { username: 'anotherUser', fullName: 'Another User' }, - }, - fields, - currentIncident: { - short_description: 'first title (created at 2020-03-13T08:34:53.450Z by Elastic User)', - description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User)', - }, - }); - expect(res).toEqual({ - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', - description: - 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User) \r\na description (updated at 2020-03-15T08:34:53.450Z by Another User)', - }); - }); - - test('add newline character to descripton', () => { - const fields = prepareFieldsForTransformation({ - params: fullParams, - mapping: finalMapping, - defaultPipes: ['informationUpdated'], - }); - - const res = transformFields({ - params: fullParams, - fields, - currentIncident: { - short_description: 'first title', - description: 'first description', - }, - }); - expect(res.description?.includes('\r\n')).toBe(true); - }); - - test('append username if fullname is undefined when create', () => { - const fields = prepareFieldsForTransformation({ - params: fullParams, - mapping: finalMapping, - }); - - const res = transformFields({ - params: { ...fullParams, createdBy: { fullName: null, username: 'elastic' } }, - fields, - }); - - expect(res).toEqual({ - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by elastic)', - description: 'a description (created at 2020-03-13T08:34:53.450Z by elastic)', - }); - }); - - test('append username if fullname is undefined when update', () => { - const fields = prepareFieldsForTransformation({ - params: { - ...fullParams, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { username: 'anotherUser', fullName: 'Another User' }, - }, - mapping: finalMapping, - defaultPipes: ['informationUpdated'], - }); - - const res = transformFields({ - params: { - ...fullParams, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { username: 'anotherUser', fullName: null }, - }, - fields, - }); - - expect(res).toEqual({ - short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by anotherUser)', - description: 'a description (updated at 2020-03-15T08:34:53.450Z by anotherUser)', - }); - }); -}); - -describe('appendField', () => { - test('prefix correctly', () => { - expect('my_prefixmy_value ').toEqual(appendField({ value: 'my_value', prefix: 'my_prefix' })); - }); - - test('suffix correctly', () => { - expect('my_value my_suffix').toEqual(appendField({ value: 'my_value', suffix: 'my_suffix' })); - }); - - test('prefix and suffix correctly', () => { - expect('my_prefixmy_value my_suffix').toEqual( - appendField({ value: 'my_value', prefix: 'my_prefix', suffix: 'my_suffix' }) - ); - }); -}); - -describe('appendInformationToField', () => { - test('creation mode', () => { - const res = appendInformationToField({ - value: 'my value', - user: 'Elastic Test User', - date: '2020-03-13T08:34:53.450Z', - mode: 'create', - }); - expect(res).toEqual('my value (created at 2020-03-13T08:34:53.450Z by Elastic Test User)'); - }); - - test('update mode', () => { - const res = appendInformationToField({ - value: 'my value', - user: 'Elastic Test User', - date: '2020-03-13T08:34:53.450Z', - mode: 'update', - }); - expect(res).toEqual('my value (updated at 2020-03-13T08:34:53.450Z by Elastic Test User)'); - }); - - test('add mode', () => { - const res = appendInformationToField({ - value: 'my value', - user: 'Elastic Test User', - date: '2020-03-13T08:34:53.450Z', - mode: 'add', - }); - expect(res).toEqual('my value (added at 2020-03-13T08:34:53.450Z by Elastic Test User)'); - }); -}); - -describe('transformComments', () => { - test('transform creation comments', () => { - const comments: Comment[] = [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]; - const res = transformComments(comments, fullParams, ['informationCreated']); - expect(res).toEqual([ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'first comment (created at 2020-03-13T08:34:53.450Z by Elastic User)', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]); - }); - - test('transform update comments', () => { - const comments: Comment[] = [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { fullName: 'Another User', username: 'anotherUser' }, - }, - ]; - const res = transformComments(comments, fullParams, ['informationUpdated']); - expect(res).toEqual([ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'first comment (updated at 2020-03-15T08:34:53.450Z by Another User)', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { fullName: 'Another User', username: 'anotherUser' }, - }, - ]); - }); - test('transform added comments', () => { - const comments: Comment[] = [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'first comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]; - const res = transformComments(comments, fullParams, ['informationAdded']); - expect(res).toEqual([ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]); - }); -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts deleted file mode 100644 index 0a26996ea8d69..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts +++ /dev/null @@ -1,125 +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 { flow } from 'lodash'; - -import { SUPPORTED_SOURCE_FIELDS } from './constants'; -import { - MapEntry, - Mapping, - AppendFieldArgs, - AppendInformationFieldArgs, - Params, - Comment, - TransformFieldsArgs, - PipedField, - PrepareFieldsForTransformArgs, - KeyAny, -} from './types'; -import { Incident } from './lib/types'; - -import * as transformers from './transformers'; -import * as i18n from './translations'; - -export const normalizeMapping = (supportedFields: string[], mapping: MapEntry[]): MapEntry[] => { - // Prevent prototype pollution and remove unsupported fields - return mapping.filter( - m => m.source !== '__proto__' && m.target !== '__proto__' && supportedFields.includes(m.source) - ); -}; - -export const buildMap = (mapping: MapEntry[]): Mapping => { - return normalizeMapping(SUPPORTED_SOURCE_FIELDS, mapping).reduce((fieldsMap, field) => { - const { source, target, actionType } = field; - fieldsMap.set(source, { target, actionType }); - fieldsMap.set(target, { target: source, actionType }); - return fieldsMap; - }, new Map()); -}; - -export const mapParams = (params: Record<string, unknown>, mapping: Mapping) => { - return Object.keys(params).reduce((prev: KeyAny, curr: string): KeyAny => { - const field = mapping.get(curr); - if (field) { - prev[field.target] = params[curr]; - } - return prev; - }, {}); -}; - -export const appendField = ({ value, prefix = '', suffix = '' }: AppendFieldArgs): string => { - return `${prefix}${value} ${suffix}`; -}; - -const t = { ...transformers } as { [index: string]: Function }; // TODO: Find a better solution exists. - -export const prepareFieldsForTransformation = ({ - params, - mapping, - defaultPipes = ['informationCreated'], -}: PrepareFieldsForTransformArgs): PipedField[] => { - return Object.keys(params.incident) - .filter(p => mapping.get(p)!.actionType !== 'nothing') - .map(p => ({ - key: p, - value: params.incident[p] as string, - actionType: mapping.get(p)!.actionType, - pipes: [...defaultPipes], - })) - .map(p => ({ - ...p, - pipes: p.actionType === 'append' ? [...p.pipes, 'append'] : p.pipes, - })); -}; - -export const transformFields = ({ - params, - fields, - currentIncident, -}: TransformFieldsArgs): Incident => { - return fields.reduce((prev: Incident, cur) => { - const transform = flow(...cur.pipes.map(p => t[p])); - prev[cur.key] = transform({ - value: cur.value, - date: params.updatedAt ?? params.createdAt, - user: - params.updatedBy != null - ? params.updatedBy.fullName ?? params.updatedBy.username - : params.createdBy.fullName ?? params.createdBy.username, - previousValue: currentIncident ? currentIncident[cur.key] : '', - }).value; - return prev; - }, {} as Incident); -}; - -export const appendInformationToField = ({ - value, - user, - date, - mode = 'create', -}: AppendInformationFieldArgs): string => { - return appendField({ - value, - suffix: i18n.FIELD_INFORMATION(mode, date, user), - }); -}; - -export const transformComments = ( - comments: Comment[], - params: Params, - pipes: string[] -): Comment[] => { - return comments.map(c => ({ - ...c, - comment: flow(...pipes.map(p => t[p]))({ - value: c.comment, - date: c.updatedAt ?? c.createdAt, - user: - c.updatedBy != null - ? c.updatedBy.fullName ?? c.updatedBy.username - : c.createdBy.fullName ?? c.createdBy.username, - }).value, - })); -}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts deleted file mode 100644 index a6c3ae88765ac..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts +++ /dev/null @@ -1,268 +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 { getActionType } from '.'; -import { ActionType, Services, ActionTypeExecutorOptions } from '../../types'; -import { validateConfig, validateSecrets, validateParams } from '../../lib'; -import { createActionTypeRegistry } from '../index.test'; -import { actionsConfigMock } from '../../actions_config.mock'; -import { actionsMock } from '../../mocks'; - -import { ACTION_TYPE_ID } from './constants'; -import * as i18n from './translations'; - -import { handleIncident } from './action_handlers'; -import { incidentResponse } from './mock'; - -jest.mock('./action_handlers'); - -const handleIncidentMock = handleIncident as jest.Mock; - -const services: Services = actionsMock.createServices(); - -let actionType: ActionType; - -const mockOptions = { - name: 'servicenow-connector', - actionTypeId: '.servicenow', - secrets: { - username: 'secret-username', - password: 'secret-password', - }, - config: { - apiUrl: 'https://service-now.com', - casesConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'work_notes', - actionType: 'append', - }, - ], - }, - }, - params: { - caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', - incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', - title: 'Incident title', - description: 'Incident description', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - comments: [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'A comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ], - }, -}; - -beforeAll(() => { - const { actionTypeRegistry } = createActionTypeRegistry(); - actionType = actionTypeRegistry.get(ACTION_TYPE_ID); -}); - -describe('get()', () => { - test('should return correct action type', () => { - expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual(i18n.NAME); - }); -}); - -describe('validateConfig()', () => { - test('should validate and pass when config is valid', () => { - const { config } = mockOptions; - expect(validateConfig(actionType, config)).toEqual(config); - }); - - test('should validate and throw error when config is invalid', () => { - expect(() => { - validateConfig(actionType, { shouldNotBeHere: true }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]"` - ); - }); - - test('should validate and pass when the servicenow url is whitelisted', () => { - actionType = getActionType({ - configurationUtilities: { - ...actionsConfigMock.create(), - ensureWhitelistedUri: url => { - expect(url).toEqual(mockOptions.config.apiUrl); - }, - }, - }); - - expect(validateConfig(actionType, mockOptions.config)).toEqual(mockOptions.config); - }); - - test('config validation returns an error if the specified URL isnt whitelisted', () => { - actionType = getActionType({ - configurationUtilities: { - ...actionsConfigMock.create(), - ensureWhitelistedUri: _ => { - throw new Error(`target url is not whitelisted`); - }, - }, - }); - - expect(() => { - validateConfig(actionType, mockOptions.config); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: error configuring servicenow action: target url is not whitelisted"` - ); - }); -}); - -describe('validateSecrets()', () => { - test('should validate and pass when secrets is valid', () => { - const { secrets } = mockOptions; - expect(validateSecrets(actionType, secrets)).toEqual(secrets); - }); - - test('should validate and throw error when secrets is invalid', () => { - expect(() => { - validateSecrets(actionType, { username: false }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"` - ); - - expect(() => { - validateSecrets(actionType, { username: false, password: 'hello' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [username]: expected value of type [string] but got [boolean]"` - ); - }); -}); - -describe('validateParams()', () => { - test('should validate and pass when params is valid', () => { - const { params } = mockOptions; - expect(validateParams(actionType, params)).toEqual(params); - }); - - test('should validate and throw error when params is invalid', () => { - expect(() => { - validateParams(actionType, {}); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action params: [caseId]: expected value of type [string] but got [undefined]"` - ); - }); -}); - -describe('execute()', () => { - beforeEach(() => { - handleIncidentMock.mockReset(); - }); - - test('should create an incident', async () => { - const actionId = 'some-id'; - const { incidentId, ...rest } = mockOptions.params; - - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config: mockOptions.config, - params: { ...rest }, - secrets: mockOptions.secrets, - services, - }; - - handleIncidentMock.mockImplementation(() => incidentResponse); - - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse }); - }); - - test('should throw an error when failed to create incident', async () => { - expect.assertions(1); - const { incidentId, ...rest } = mockOptions.params; - - const actionId = 'some-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config: mockOptions.config, - params: { ...rest }, - secrets: mockOptions.secrets, - services, - }; - const errorMessage = 'Failed to create incident'; - - handleIncidentMock.mockImplementation(() => { - throw new Error(errorMessage); - }); - - try { - await actionType.executor(executorOptions); - } catch (error) { - expect(error.message).toEqual(errorMessage); - } - }); - - test('should update an incident', async () => { - const actionId = 'some-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config: mockOptions.config, - params: { - ...mockOptions.params, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { fullName: 'Another User', username: 'anotherUser' }, - }, - secrets: mockOptions.secrets, - services, - }; - - handleIncidentMock.mockImplementation(() => incidentResponse); - - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse }); - }); - - test('should throw an error when failed to update an incident', async () => { - expect.assertions(1); - - const actionId = 'some-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config: mockOptions.config, - params: { - ...mockOptions.params, - updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { fullName: 'Another User', username: 'anotherUser' }, - }, - secrets: mockOptions.secrets, - services, - }; - const errorMessage = 'Failed to update incident'; - - handleIncidentMock.mockImplementation(() => { - throw new Error(errorMessage); - }); - - try { - await actionType.executor(executorOptions); - } catch (error) { - expect(error.message).toEqual(errorMessage); - } - }); -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index 5066190d4fe56..dbb536d2fa53d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -4,108 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { curry, isEmpty } from 'lodash'; -import { schema } from '@kbn/config-schema'; -import { - ActionType, - ActionTypeExecutorOptions, - ActionTypeExecutorResult, - ExecutorType, -} from '../../types'; -import { ActionsConfigurationUtilities } from '../../actions_config'; -import { ServiceNow } from './lib'; - -import * as i18n from './translations'; - -import { ACTION_TYPE_ID } from './constants'; -import { ConfigType, SecretsType, Comment, ExecutorParams } from './types'; - -import { ConfigSchemaProps, SecretsSchemaProps, ParamsSchema } from './schema'; - -import { buildMap, mapParams } from './helpers'; -import { handleIncident } from './action_handlers'; - -function validateConfig( - configurationUtilities: ActionsConfigurationUtilities, - configObject: ConfigType -) { - try { - if (isEmpty(configObject.casesConfiguration.mapping)) { - return i18n.MAPPING_EMPTY; - } - - configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); - } catch (whitelistError) { - return i18n.WHITE_LISTED_ERROR(whitelistError.message); - } -} - -function validateSecrets( - configurationUtilities: ActionsConfigurationUtilities, - secrets: SecretsType -) {} +import { createConnector } from '../case/utils'; -// action type definition -export function getActionType({ - configurationUtilities, - executor = serviceNowExecutor, -}: { - configurationUtilities: ActionsConfigurationUtilities; - executor?: ExecutorType; -}): ActionType { - return { - id: ACTION_TYPE_ID, - name: i18n.NAME, - minimumLicenseRequired: 'platinum', - validate: { - config: schema.object(ConfigSchemaProps, { - validate: curry(validateConfig)(configurationUtilities), - }), - secrets: schema.object(SecretsSchemaProps, { - validate: curry(validateSecrets)(configurationUtilities), - }), - params: ParamsSchema, - }, - executor, - }; -} - -// action executor - -async function serviceNowExecutor( - execOptions: ActionTypeExecutorOptions -): Promise<ActionTypeExecutorResult> { - const actionId = execOptions.actionId; - const { - apiUrl, - casesConfiguration: { mapping: configurationMapping }, - } = execOptions.config as ConfigType; - const { username, password } = execOptions.secrets as SecretsType; - const params = execOptions.params as ExecutorParams; - const { comments, incidentId, ...restParams } = params; - - const mapping = buildMap(configurationMapping); - const incident = mapParams((restParams as unknown) as Record<string, unknown>, mapping); - const serviceNow = new ServiceNow({ url: apiUrl, username, password }); - - const handlerInput = { - incidentId, - serviceNow, - params: { ...params, incident }, - comments: comments as Comment[], - mapping, - }; - - const res: Pick<ActionTypeExecutorResult, 'status'> & - Pick<ActionTypeExecutorResult, 'actionId'> = { - status: 'ok', - actionId, - }; - - const data = await handleIncident(handlerInput); - - return { - ...res, - data, - }; -} +import { api } from './api'; +import { config } from './config'; +import { validate } from './validators'; +import { createExternalService } from './service'; +import { + ExternalIncidentServiceConfiguration, + ExternalIncidentServiceSecretConfiguration, +} from '../case/schema'; + +export const getActionType = createConnector({ + api, + config, + validate, + createExternalService, + validationSchema: { + config: ExternalIncidentServiceConfiguration, + secrets: ExternalIncidentServiceSecretConfiguration, + }, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts deleted file mode 100644 index 3f102ae19f437..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts +++ /dev/null @@ -1,13 +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. - */ - -export const API_VERSION = 'v2'; -export const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; -export const USER_URL = `api/now/${API_VERSION}/table/sys_user?user_name=`; -export const COMMENT_URL = `api/now/${API_VERSION}/table/incident`; - -// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html -export const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts deleted file mode 100644 index 40eeb0f920f82..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts +++ /dev/null @@ -1,334 +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 axios from 'axios'; -import { ServiceNow } from '.'; -import { instance, params } from '../mock'; - -jest.mock('axios'); - -axios.create = jest.fn(() => axios); -const axiosMock = (axios as unknown) as jest.Mock; - -let serviceNow: ServiceNow; - -const testMissingConfiguration = (field: string) => { - expect.assertions(1); - try { - new ServiceNow({ ...instance, [field]: '' }); - } catch (error) { - expect(error.message).toEqual('[Action][ServiceNow]: Wrong configuration.'); - } -}; - -const prependInstanceUrl = (url: string): string => `${instance.url}/${url}`; - -describe('ServiceNow lib', () => { - beforeEach(() => { - serviceNow = new ServiceNow(instance); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test('should thrown an error if url is missing', () => { - testMissingConfiguration('url'); - }); - - test('should thrown an error if username is missing', () => { - testMissingConfiguration('username'); - }); - - test('should thrown an error if password is missing', () => { - testMissingConfiguration('password'); - }); - - test('get user id', async () => { - axiosMock.mockResolvedValue({ - status: 200, - headers: { - 'content-type': 'application/json', - }, - data: { result: [{ sys_id: '123' }] }, - }); - - const res = await serviceNow.getUserID(); - const [url, { method }] = axiosMock.mock.calls[0]; - - expect(url).toEqual(prependInstanceUrl('api/now/v2/table/sys_user?user_name=username')); - expect(method).toEqual('get'); - expect(res).toEqual('123'); - }); - - test('create incident', async () => { - axiosMock.mockResolvedValue({ - status: 200, - headers: { - 'content-type': 'application/json', - }, - data: { result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, - }); - - const res = await serviceNow.createIncident({ - short_description: 'A title', - description: 'A description', - caller_id: '123', - }); - const [url, { method, data }] = axiosMock.mock.calls[0]; - - expect(url).toEqual(prependInstanceUrl('api/now/v2/table/incident')); - expect(method).toEqual('post'); - expect(data).toEqual({ - short_description: 'A title', - description: 'A description', - caller_id: '123', - }); - - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('update incident', async () => { - axiosMock.mockResolvedValue({ - status: 200, - headers: { - 'content-type': 'application/json', - }, - data: { result: { sys_id: '123', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, - }); - - const res = await serviceNow.updateIncident('123', { - short_description: params.title, - }); - const [url, { method, data }] = axiosMock.mock.calls[0]; - - expect(url).toEqual(prependInstanceUrl(`api/now/v2/table/incident/123`)); - expect(method).toEqual('patch'); - expect(data).toEqual({ short_description: params.title }); - expect(res).toEqual({ - incidentId: '123', - number: 'INC01', - pushedDate: '2020-03-10T12:24:20.000Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', - }); - }); - - test('create comment', async () => { - axiosMock.mockResolvedValue({ - status: 200, - headers: { - 'content-type': 'application/json', - }, - data: { result: { sys_updated_on: '2020-03-10 12:24:20' } }, - }); - - const comment = { - commentId: '456', - version: 'WzU3LDFd', - comment: 'A comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }; - - const res = await serviceNow.createComment('123', comment, 'comments'); - - const [url, { method, data }] = axiosMock.mock.calls[0]; - - expect(url).toEqual(prependInstanceUrl(`api/now/v2/table/incident/123`)); - expect(method).toEqual('patch'); - expect(data).toEqual({ - comments: 'A comment', - }); - - expect(res).toEqual({ - commentId: '456', - pushedDate: '2020-03-10T12:24:20.000Z', - }); - }); - - test('create batch comment', async () => { - axiosMock.mockReturnValueOnce({ - status: 200, - headers: { - 'content-type': 'application/json', - }, - data: { result: { sys_updated_on: '2020-03-10 12:24:20' } }, - }); - - axiosMock.mockReturnValueOnce({ - status: 200, - headers: { - 'content-type': 'application/json', - }, - data: { result: { sys_updated_on: '2020-03-10 12:25:20' } }, - }); - - const comments = [ - { - commentId: '123', - version: 'WzU3LDFd', - comment: 'A comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - { - commentId: '456', - version: 'WzU3LDFd', - comment: 'A second comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - ]; - const res = await serviceNow.batchCreateComments('000', comments, 'comments'); - - comments.forEach((comment, index) => { - const [url, { method, data }] = axiosMock.mock.calls[index]; - expect(url).toEqual(prependInstanceUrl('api/now/v2/table/incident/000')); - expect(method).toEqual('patch'); - expect(data).toEqual({ - comments: comment.comment, - }); - expect(res).toEqual([ - { commentId: '123', pushedDate: '2020-03-10T12:24:20.000Z' }, - { commentId: '456', pushedDate: '2020-03-10T12:25:20.000Z' }, - ]); - }); - }); - - test('throw if not status is not ok', async () => { - expect.assertions(1); - - axiosMock.mockResolvedValue({ - status: 401, - headers: { - 'content-type': 'application/json', - }, - }); - try { - await serviceNow.getUserID(); - } catch (error) { - expect(error.message).toEqual( - '[Action][ServiceNow]: Unable to get user id. Error: [ServiceNow]: Instance is not alive.' - ); - } - }); - - test('throw if not content-type is not application/json', async () => { - expect.assertions(1); - - axiosMock.mockResolvedValue({ - status: 200, - headers: { - 'content-type': 'application/html', - }, - }); - try { - await serviceNow.getUserID(); - } catch (error) { - expect(error.message).toEqual( - '[Action][ServiceNow]: Unable to get user id. Error: [ServiceNow]: Instance is not alive.' - ); - } - }); - - test('check error when getting user', async () => { - expect.assertions(1); - - axiosMock.mockImplementationOnce(() => { - throw new Error('Bad request.'); - }); - try { - await serviceNow.getUserID(); - } catch (error) { - expect(error.message).toEqual( - '[Action][ServiceNow]: Unable to get user id. Error: Bad request.' - ); - } - }); - - test('check error when getting incident', async () => { - expect.assertions(1); - - axiosMock.mockImplementationOnce(() => { - throw new Error('Bad request.'); - }); - try { - await serviceNow.getIncident('123'); - } catch (error) { - expect(error.message).toEqual( - '[Action][ServiceNow]: Unable to get incident with id 123. Error: Bad request.' - ); - } - }); - - test('check error when creating incident', async () => { - expect.assertions(1); - - axiosMock.mockImplementationOnce(() => { - throw new Error('Bad request.'); - }); - try { - await serviceNow.createIncident({ short_description: 'title' }); - } catch (error) { - expect(error.message).toEqual( - '[Action][ServiceNow]: Unable to create incident. Error: Bad request.' - ); - } - }); - - test('check error when updating incident', async () => { - expect.assertions(1); - - axiosMock.mockImplementationOnce(() => { - throw new Error('Bad request.'); - }); - try { - await serviceNow.updateIncident('123', { short_description: 'title' }); - } catch (error) { - expect(error.message).toEqual( - '[Action][ServiceNow]: Unable to update incident with id 123. Error: Bad request.' - ); - } - }); - - test('check error when creating comment', async () => { - expect.assertions(1); - - axiosMock.mockImplementationOnce(() => { - throw new Error('Bad request.'); - }); - try { - await serviceNow.createComment( - '123', - { - commentId: '456', - version: 'WzU3LDFd', - comment: 'A second comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, - }, - 'comment' - ); - } catch (error) { - expect(error.message).toEqual( - '[Action][ServiceNow]: Unable to create comment at incident with id 123. Error: Bad request.' - ); - } - }); -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts deleted file mode 100644 index ed9cfe67a19a1..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts +++ /dev/null @@ -1,186 +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 axios, { AxiosInstance, Method, AxiosResponse } from 'axios'; - -import { INCIDENT_URL, USER_URL, COMMENT_URL, VIEW_INCIDENT_URL } from './constants'; -import { Instance, Incident, IncidentResponse, UpdateIncident, CommentResponse } from './types'; -import { Comment } from '../types'; - -const validStatusCodes = [200, 201]; - -class ServiceNow { - private readonly incidentUrl: string; - private readonly commentUrl: string; - private readonly userUrl: string; - private readonly axios: AxiosInstance; - - constructor(private readonly instance: Instance) { - if ( - !this.instance || - !this.instance.url || - !this.instance.username || - !this.instance.password - ) { - throw Error('[Action][ServiceNow]: Wrong configuration.'); - } - - this.incidentUrl = `${this.instance.url}/${INCIDENT_URL}`; - this.commentUrl = `${this.instance.url}/${COMMENT_URL}`; - this.userUrl = `${this.instance.url}/${USER_URL}`; - this.axios = axios.create({ - auth: { username: this.instance.username, password: this.instance.password }, - }); - } - - private _throwIfNotAlive(status: number, contentType: string) { - if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) { - throw new Error('[ServiceNow]: Instance is not alive.'); - } - } - - private async _request({ - url, - method = 'get', - data = {}, - }: { - url: string; - method?: Method; - data?: unknown; - }): Promise<AxiosResponse> { - const res = await this.axios(url, { method, data }); - this._throwIfNotAlive(res.status, res.headers['content-type']); - return res; - } - - private _patch({ url, data }: { url: string; data: unknown }): Promise<AxiosResponse> { - return this._request({ - url, - method: 'patch', - data, - }); - } - - private _addTimeZoneToDate(date: string, timezone = 'GMT'): string { - return `${date} GMT`; - } - - private _getErrorMessage(msg: string) { - return `[Action][ServiceNow]: ${msg}`; - } - - private _getIncidentViewURL(id: string) { - return `${this.instance.url}/${VIEW_INCIDENT_URL}${id}`; - } - - async getUserID(): Promise<string> { - try { - const res = await this._request({ url: `${this.userUrl}${this.instance.username}` }); - return res.data.result[0].sys_id; - } catch (error) { - throw new Error(this._getErrorMessage(`Unable to get user id. Error: ${error.message}`)); - } - } - - async getIncident(incidentId: string) { - try { - const res = await this._request({ - url: `${this.incidentUrl}/${incidentId}`, - }); - - return { ...res.data.result }; - } catch (error) { - throw new Error( - this._getErrorMessage( - `Unable to get incident with id ${incidentId}. Error: ${error.message}` - ) - ); - } - } - - async createIncident(incident: Incident): Promise<IncidentResponse> { - try { - const res = await this._request({ - url: `${this.incidentUrl}`, - method: 'post', - data: { ...incident }, - }); - - return { - number: res.data.result.number, - incidentId: res.data.result.sys_id, - pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), - url: this._getIncidentViewURL(res.data.result.sys_id), - }; - } catch (error) { - throw new Error(this._getErrorMessage(`Unable to create incident. Error: ${error.message}`)); - } - } - - async updateIncident(incidentId: string, incident: UpdateIncident): Promise<IncidentResponse> { - try { - const res = await this._patch({ - url: `${this.incidentUrl}/${incidentId}`, - data: { ...incident }, - }); - - return { - number: res.data.result.number, - incidentId: res.data.result.sys_id, - pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), - url: this._getIncidentViewURL(res.data.result.sys_id), - }; - } catch (error) { - throw new Error( - this._getErrorMessage( - `Unable to update incident with id ${incidentId}. Error: ${error.message}` - ) - ); - } - } - - async batchCreateComments( - incidentId: string, - comments: Comment[], - field: string - ): Promise<CommentResponse[]> { - // Create comments sequentially. - const promises = comments.reduce(async (prevPromise, currentComment) => { - const totalComments = await prevPromise; - const res = await this.createComment(incidentId, currentComment, field); - return [...totalComments, res]; - }, Promise.resolve([] as CommentResponse[])); - - const res = await promises; - return res; - } - - async createComment( - incidentId: string, - comment: Comment, - field: string - ): Promise<CommentResponse> { - try { - const res = await this._patch({ - url: `${this.commentUrl}/${incidentId}`, - data: { [field]: comment.comment }, - }); - - return { - commentId: comment.commentId, - pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), - }; - } catch (error) { - throw new Error( - this._getErrorMessage( - `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}` - ) - ); - } - } -} - -export { ServiceNow }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts deleted file mode 100644 index a65e417dbc486..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.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. - */ - -export interface Instance { - url: string; - username: string; - password: string; -} - -export interface Incident { - short_description: string; - description?: string; - caller_id?: string; - [index: string]: string | undefined; -} - -export interface IncidentResponse { - number: string; - incidentId: string; - pushedDate: string; - url: string; -} - -export interface CommentResponse { - commentId: string; - pushedDate: string; -} - -export type UpdateIncident = Partial<Incident>; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts deleted file mode 100644 index 06c006fb37825..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts +++ /dev/null @@ -1,115 +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 { MapEntry, Mapping, ExecutorParams } from './types'; -import { Incident } from './lib/types'; - -const mapping: MapEntry[] = [ - { source: 'title', target: 'short_description', actionType: 'overwrite' }, - { source: 'description', target: 'description', actionType: 'append' }, - { source: 'comments', target: 'comments', actionType: 'append' }, -]; - -const finalMapping: Mapping = new Map(); - -finalMapping.set('title', { - target: 'short_description', - actionType: 'overwrite', -}); - -finalMapping.set('description', { - target: 'description', - actionType: 'append', -}); - -finalMapping.set('comments', { - target: 'comments', - actionType: 'append', -}); - -finalMapping.set('short_description', { - target: 'title', - actionType: 'overwrite', -}); - -const params: ExecutorParams = { - caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', - incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, - title: 'Incident title', - description: 'Incident description', - comments: [ - { - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - version: 'WzU3LDFd', - comment: 'A comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, - }, - { - commentId: 'e3db587f-ca27-4ae9-ad2e-31f2dcc9bd0d', - version: 'WlK3LDFd', - comment: 'Another comment', - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic User', username: 'elastic' }, - }, - ], -}; - -const incidentResponse = { - incidentId: 'c816f79cc0a8016401c5a33be04be441', - number: 'INC0010001', - pushedDate: '2020-03-13T08:34:53.450Z', - url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', -}; - -const userId = '2e9a0a5e2f79001016ab51172799b670'; - -const axiosResponse = { - status: 200, - headers: { - 'content-type': 'application/json', - }, -}; -const userIdResponse = { - result: [{ sys_id: userId }], -}; - -const incidentAxiosResponse = { - result: { sys_id: incidentResponse.incidentId, number: incidentResponse.number }, -}; - -const instance = { - url: 'https://instance.service-now.com', - username: 'username', - password: 'password', -}; - -const incident: Incident = { - short_description: params.title, - description: params.description, - caller_id: userId, -}; - -export { - mapping, - finalMapping, - params, - incidentResponse, - incidentAxiosResponse, - userId, - userIdResponse, - axiosResponse, - instance, - incident, -}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts new file mode 100644 index 0000000000000..37228380910b3 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts @@ -0,0 +1,117 @@ +/* + * 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 { + ExternalService, + PushToServiceApiParams, + ExecutorSubActionPushParams, + MapRecord, +} from '../case/types'; + +const createMock = (): jest.Mocked<ExternalService> => { + const service = { + getIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + short_description: 'title from servicenow', + description: 'description from servicenow', + }) + ), + createIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: 'incident-1', + title: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }) + ), + updateIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: 'incident-2', + title: 'INC02', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123', + }) + ), + createComment: jest.fn(), + }; + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-1', + pushedDate: '2020-03-10T12:24:20.000Z', + }) + ); + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-2', + pushedDate: '2020-03-10T12:24:20.000Z', + }) + ); + return service; +}; + +const externalServiceMock = { + create: createMock, +}; + +const mapping: Map<string, Partial<MapRecord>> = new Map(); + +mapping.set('title', { + target: 'short_description', + actionType: 'overwrite', +}); + +mapping.set('description', { + target: 'description', + actionType: 'overwrite', +}); + +mapping.set('comments', { + target: 'comments', + actionType: 'append', +}); + +mapping.set('short_description', { + target: 'title', + actionType: 'overwrite', +}); + +const executorParams: ExecutorSubActionPushParams = { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + externalId: 'incident-3', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-03-13T08:34:53.450Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + ], +}; + +const apiParams: PushToServiceApiParams = { + ...executorParams, + externalCase: { short_description: 'Incident title', description: 'Incident description' }, +}; + +export { externalServiceMock, mapping, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts deleted file mode 100644 index 889b57c8e92e2..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; - -export const MapEntrySchema = schema.object({ - source: schema.string(), - target: schema.string(), - actionType: schema.oneOf([ - schema.literal('nothing'), - schema.literal('overwrite'), - schema.literal('append'), - ]), -}); - -export const CasesConfigurationSchema = schema.object({ - mapping: schema.arrayOf(MapEntrySchema), -}); - -export const ConfigSchemaProps = { - apiUrl: schema.string(), - casesConfiguration: CasesConfigurationSchema, -}; - -export const ConfigSchema = schema.object(ConfigSchemaProps); - -export const SecretsSchemaProps = { - password: schema.string(), - username: schema.string(), -}; - -export const SecretsSchema = schema.object(SecretsSchemaProps); - -export const UserSchema = schema.object({ - fullName: schema.nullable(schema.string()), - username: schema.string(), -}); - -const EntityInformationSchemaProps = { - createdAt: schema.string(), - createdBy: UserSchema, - updatedAt: schema.nullable(schema.string()), - updatedBy: schema.nullable(UserSchema), -}; - -export const EntityInformationSchema = schema.object(EntityInformationSchemaProps); - -export const CommentSchema = schema.object({ - commentId: schema.string(), - comment: schema.string(), - version: schema.maybe(schema.string()), - ...EntityInformationSchemaProps, -}); - -export const ExecutorAction = schema.oneOf([ - schema.literal('newIncident'), - schema.literal('updateIncident'), -]); - -export const ParamsSchema = schema.object({ - caseId: schema.string(), - title: schema.string(), - comments: schema.maybe(schema.arrayOf(CommentSchema)), - description: schema.maybe(schema.string()), - incidentId: schema.nullable(schema.string()), - ...EntityInformationSchemaProps, -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts new file mode 100644 index 0000000000000..f65cd5430560e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -0,0 +1,255 @@ +/* + * 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 axios from 'axios'; + +import { createExternalService } from './service'; +import * as utils from '../case/utils'; +import { ExternalService } from '../case/types'; + +jest.mock('axios'); +jest.mock('../case/utils', () => { + const originalUtils = jest.requireActual('../case/utils'); + return { + ...originalUtils, + request: jest.fn(), + patch: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const patchMock = utils.patch as jest.Mock; + +describe('ServiceNow service', () => { + let service: ExternalService; + + beforeAll(() => { + service = createExternalService({ + config: { apiUrl: 'https://dev102283.service-now.com' }, + secrets: { username: 'admin', password: 'admin' }, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createExternalService', () => { + test('throws without url', () => { + expect(() => + createExternalService({ + config: { apiUrl: null }, + secrets: { username: 'admin', password: 'admin' }, + }) + ).toThrow(); + }); + + test('throws without username', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com' }, + secrets: { username: '', password: 'admin' }, + }) + ).toThrow(); + }); + + test('throws without password', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com' }, + secrets: { username: '', password: undefined }, + }) + ).toThrow(); + }); + }); + + describe('getIncident', () => { + test('it returns the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01' } }, + })); + const res = await service.getIncident('1'); + expect(res).toEqual({ sys_id: '1', number: 'INC01' }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01' } }, + })); + + await service.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + expect(service.getIncident('1')).rejects.toThrow( + 'Unable to get incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createIncident', () => { + test('it creates the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, + })); + + const res = await service.createIncident({ + incident: { short_description: 'title', description: 'desc' }, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, + })); + + await service.createIncident({ + incident: { short_description: 'title', description: 'desc' }, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://dev102283.service-now.com/api/now/v2/table/incident', + method: 'post', + data: { short_description: 'title', description: 'desc' }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createIncident({ + incident: { short_description: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred' + ); + }); + }); + + describe('updateIncident', () => { + test('it updates the incident correctly', async () => { + patchMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, + })); + + const res = await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' }, + }); + + expect(res).toEqual({ + title: 'INC01', + id: '1', + pushedDate: '2020-03-10T12:24:20.000Z', + url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1', + }); + }); + + test('it should call request with correct arguments', async () => { + patchMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, + })); + + await service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' }, + }); + + expect(patchMock).toHaveBeenCalledWith({ + axios, + url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + data: { short_description: 'title', description: 'desc' }, + }); + }); + + test('it should throw an error', async () => { + patchMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.updateIncident({ + incidentId: '1', + incident: { short_description: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createComment', () => { + test('it creates the comment correctly', async () => { + patchMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, + })); + + const res = await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }); + + expect(res).toEqual({ + commentId: 'comment-1', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('it should call request with correct arguments', async () => { + patchMock.mockImplementation(() => ({ + data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, + })); + + await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'my_field', + }); + + expect(patchMock).toHaveBeenCalledWith({ + axios, + url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', + data: { my_field: 'comment' }, + }); + }); + + test('it should throw an error', async () => { + patchMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }) + ).rejects.toThrow( + '[Action][ServiceNow]: Unable to create comment at incident with id 1. Error: An error has occurred' + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts new file mode 100644 index 0000000000000..541fefce2f2ff --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -0,0 +1,138 @@ +/* + * 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 axios from 'axios'; + +import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types'; +import { addTimeZoneToDate, patch, request, getErrorMessage } from '../case/utils'; + +import * as i18n from './translations'; +import { + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + CreateIncidentRequest, + UpdateIncidentRequest, + CreateCommentRequest, +} from './types'; + +const API_VERSION = 'v2'; +const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; +const COMMENT_URL = `api/now/${API_VERSION}/table/incident`; + +// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html +const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`; + +export const createExternalService = ({ + config, + secrets, +}: ExternalServiceCredentials): ExternalService => { + const { apiUrl: url } = config as ServiceNowPublicConfigurationType; + const { username, password } = secrets as ServiceNowSecretConfigurationType; + + if (!url || !username || !password) { + throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); + } + + const incidentUrl = `${url}/${INCIDENT_URL}`; + const commentUrl = `${url}/${COMMENT_URL}`; + const axiosInstance = axios.create({ + auth: { username, password }, + }); + + const getIncidentViewURL = (id: string) => { + return `${url}/${VIEW_INCIDENT_URL}${id}`; + }; + + const getIncident = async (id: string) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}/${id}`, + }); + + return { ...res.data.result }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`) + ); + } + }; + + const createIncident = async ({ incident }: ExternalServiceParams) => { + try { + const res = await request<CreateIncidentRequest>({ + axios: axiosInstance, + url: `${incidentUrl}`, + method: 'post', + data: { ...incident }, + }); + + return { + title: res.data.result.number, + id: res.data.result.sys_id, + pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), + url: getIncidentViewURL(res.data.result.sys_id), + }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`) + ); + } + }; + + const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + try { + const res = await patch<UpdateIncidentRequest>({ + axios: axiosInstance, + url: `${incidentUrl}/${incidentId}`, + data: { ...incident }, + }); + + return { + title: res.data.result.number, + id: res.data.result.sys_id, + pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + url: getIncidentViewURL(res.data.result.sys_id), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to update incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => { + try { + const res = await patch<CreateCommentRequest>({ + axios: axiosInstance, + url: `${commentUrl}/${incidentId}`, + data: { [field]: comment.comment }, + }); + + return { + commentId: comment.commentId, + pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + return { + getIncident, + createIncident, + updateIncident, + createComment, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/transformers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/transformers.ts deleted file mode 100644 index dc0a03fab8c71..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/transformers.ts +++ /dev/null @@ -1,43 +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 { TransformerArgs } from './types'; -import * as i18n from './translations'; - -export const informationCreated = ({ - value, - date, - user, - ...rest -}: TransformerArgs): TransformerArgs => ({ - value: `${value} ${i18n.FIELD_INFORMATION('create', date, user)}`, - ...rest, -}); - -export const informationUpdated = ({ - value, - date, - user, - ...rest -}: TransformerArgs): TransformerArgs => ({ - value: `${value} ${i18n.FIELD_INFORMATION('update', date, user)}`, - ...rest, -}); - -export const informationAdded = ({ - value, - date, - user, - ...rest -}: TransformerArgs): TransformerArgs => ({ - value: `${value} ${i18n.FIELD_INFORMATION('add', date, user)}`, - ...rest, -}); - -export const append = ({ value, previousValue, ...rest }: TransformerArgs): TransformerArgs => ({ - value: previousValue ? `${previousValue} \r\n${value}` : `${value}`, - ...rest, -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index 3b216a6c3260a..3d6138169c4cc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -6,77 +6,6 @@ import { i18n } from '@kbn/i18n'; -export const API_URL_REQUIRED = i18n.translate( - 'xpack.actions.builtin.servicenow.servicenowApiNullError', - { - defaultMessage: 'ServiceNow [apiUrl] is required', - } -); - -export const WHITE_LISTED_ERROR = (message: string) => - i18n.translate('xpack.actions.builtin.servicenow.servicenowApiWhitelistError', { - defaultMessage: 'error configuring servicenow action: {message}', - values: { - message, - }, - }); - -export const NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', { +export const NAME = i18n.translate('xpack.actions.builtin.case.servicenowTitle', { defaultMessage: 'ServiceNow', }); - -export const MAPPING_EMPTY = i18n.translate('xpack.actions.builtin.servicenow.emptyMapping', { - defaultMessage: '[casesConfiguration.mapping]: expected non-empty but got empty', -}); - -export const ERROR_POSTING = i18n.translate( - 'xpack.actions.builtin.servicenow.postingErrorMessage', - { - defaultMessage: 'error posting servicenow event', - } -); - -export const RETRY_POSTING = (status: number) => - i18n.translate('xpack.actions.builtin.servicenow.postingRetryErrorMessage', { - defaultMessage: 'error posting servicenow event: http status {status}, retry later', - values: { - status, - }, - }); - -export const UNEXPECTED_STATUS = (status: number) => - i18n.translate('xpack.actions.builtin.servicenow.postingUnexpectedErrorMessage', { - defaultMessage: 'error posting servicenow event: unexpected status {status}', - values: { - status, - }, - }); - -export const FIELD_INFORMATION = ( - mode: string, - date: string | undefined, - user: string | undefined -) => { - switch (mode) { - case 'create': - return i18n.translate('xpack.actions.builtin.servicenow.informationCreated', { - values: { date, user }, - defaultMessage: '(created at {date} by {user})', - }); - case 'update': - return i18n.translate('xpack.actions.builtin.servicenow.informationUpdated', { - values: { date, user }, - defaultMessage: '(updated at {date} by {user})', - }); - case 'add': - return i18n.translate('xpack.actions.builtin.servicenow.informationAdded', { - values: { date, user }, - defaultMessage: '(added at {date} by {user})', - }); - default: - return i18n.translate('xpack.actions.builtin.servicenow.informationDefault', { - values: { date, user }, - defaultMessage: '(created at {date} by {user})', - }); - } -}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index c5ef282aeffa7..d8476b7dca54a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -4,100 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TypeOf } from '@kbn/config-schema'; +export { + ExternalIncidentServiceConfiguration as ServiceNowPublicConfigurationType, + ExternalIncidentServiceSecretConfiguration as ServiceNowSecretConfigurationType, +} from '../case/types'; -import { - ConfigSchema, - SecretsSchema, - ParamsSchema, - CasesConfigurationSchema, - MapEntrySchema, - CommentSchema, -} from './schema'; - -import { ServiceNow } from './lib'; -import { Incident, IncidentResponse } from './lib/types'; - -// config definition -export type ConfigType = TypeOf<typeof ConfigSchema>; - -// secrets definition -export type SecretsType = TypeOf<typeof SecretsSchema>; - -export type ExecutorParams = TypeOf<typeof ParamsSchema>; - -export type CasesConfigurationType = TypeOf<typeof CasesConfigurationSchema>; -export type MapEntry = TypeOf<typeof MapEntrySchema>; -export type Comment = TypeOf<typeof CommentSchema>; - -export type Mapping = Map<string, Omit<MapEntry, 'source'>>; - -export interface Params extends ExecutorParams { - incident: Record<string, unknown>; +export interface CreateIncidentRequest { + summary: string; + description: string; } -export interface CreateHandlerArguments { - serviceNow: ServiceNow; - params: Params; - comments: Comment[]; - mapping: Mapping; -} - -export type UpdateHandlerArguments = CreateHandlerArguments & { - incidentId: string; -}; - -export type IncidentHandlerArguments = CreateHandlerArguments & { - incidentId: string | null; -}; -export interface HandlerResponse extends IncidentResponse { - comments?: SimpleComment[]; -} - -export interface SimpleComment { - commentId: string; - pushedDate: string; -} - -export interface AppendFieldArgs { - value: string; - prefix?: string; - suffix?: string; -} - -export interface KeyAny { - [index: string]: unknown; -} - -export interface AppendInformationFieldArgs { - value: string; - user: string; - date: string; - mode: string; -} - -export interface TransformerArgs { - value: string; - date?: string; - user?: string; - previousValue?: string; -} - -export interface PrepareFieldsForTransformArgs { - params: Params; - mapping: Mapping; - defaultPipes?: string[]; -} - -export interface PipedField { - key: string; - value: string; - actionType: string; - pipes: string[]; -} +export type UpdateIncidentRequest = Partial<CreateIncidentRequest>; -export interface TransformFieldsArgs { - params: Params; - fields: PipedField[]; - currentIncident?: Incident; +export interface CreateCommentRequest { + [key: string]: string; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts new file mode 100644 index 0000000000000..7226071392bc6 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/validators.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateCommonConfig, validateCommonSecrets } from '../case/validators'; +import { ExternalServiceValidation } from '../case/types'; + +export const validate: ExternalServiceValidation = { + config: validateCommonConfig, + secrets: validateCommonSecrets, +}; 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 2ba6f9baca90d..87ec3f8fc7ec1 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 62f16890cade2..9c73f07289dc9 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', () => ( - <Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} /> - )) + .add('default', () => <Demo actionFactories={[dashboardFactory, urlFactory]} />) .add('Only one factory is available', () => ( // to make sure layout doesn't break - <Demo actionFactories={[dashboardDrilldownActionFactory]} /> + <Demo actionFactories={[dashboardFactory]} /> )) .add('Long list of action factories', () => ( // to make sure layout doesn't break <Demo actionFactories={[ - dashboardDrilldownActionFactory, - urlDrilldownActionFactory, - dashboardDrilldownActionFactory, - urlDrilldownActionFactory, - dashboardDrilldownActionFactory, - urlDrilldownActionFactory, - dashboardDrilldownActionFactory, - urlDrilldownActionFactory, + dashboardFactory, + urlFactory, + dashboardFactory, + urlFactory, + dashboardFactory, + urlFactory, + dashboardFactory, + urlFactory, ]} /> )); 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 aea47be693b8f..f43d832b1edae 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( - <Demo actionFactories={[dashboardDrilldownActionFactory, urlDrilldownActionFactory]} /> - ); + const screen = render(<Demo actionFactories={[dashboardFactory, urlFactory]} />); // 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(<Demo actionFactories={[urlDrilldownActionFactory]} />); + const screen = render(<Demo actionFactories={[urlFactory]} />); // 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 ef4a0f76de9ed..867ead688d23d 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<Config extends ActionBaseConfig = ActionBaseConfig> { - type: string; // TODO: type should be tied to Action and ActionByType - displayName: string; - iconType?: string; - wizard: React.FC<ActionFactoryWizardProps<Config>>; - createConfig: () => Config; - isValid: (config: Config) => boolean; -} - -export interface ActionFactoryWizardProps<Config extends ActionBaseConfig> { - 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<ActionFactory<any>>; // any here to be able to pass array of ActionFactory<Config> 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<ActionWizardProps> = ({ @@ -73,6 +58,7 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({ 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<ActionWizardProps> = ({ onDeselect={() => { onActionFactoryChange(null); }} + context={context} config={config} onConfigChange={newConfig => { onConfigChange(newConfig); @@ -97,6 +84,7 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({ return ( <ActionFactorySelector + context={context} actionFactories={actionFactories} onActionFactorySelected={actionFactory => { onActionFactoryChange(actionFactory); @@ -105,15 +93,16 @@ export const ActionWizard: React.FC<ActionWizardProps> = ({ ); }; -interface SelectedActionFactoryProps<Config extends ActionBaseConfig = ActionBaseConfig> { - actionFactory: ActionFactory<Config>; - 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<SelectedActionFactoryProps> = ({ actionFactory, @@ -121,28 +110,28 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({ showDeselect, onConfigChange, config, + context, }) => { return ( <div className="auaActionWizard__selectedActionFactoryContainer" - data-test-subj={TEST_SUBJ_SELECTED_ACTION_FACTORY} - data-testid={TEST_SUBJ_SELECTED_ACTION_FACTORY} + data-test-subj={`${TEST_SUBJ_SELECTED_ACTION_FACTORY}-${actionFactory.id}`} > <header> <EuiFlexGroup alignItems="center" gutterSize="s"> - {actionFactory.iconType && ( + {actionFactory.getIconType(context) && ( <EuiFlexItem grow={false}> - <EuiIcon type={actionFactory.iconType} size="m" /> + <EuiIcon type={actionFactory.getIconType(context)!} size="m" /> </EuiFlexItem> )} <EuiFlexItem grow={true}> <EuiText> - <h4>{actionFactory.displayName}</h4> + <h4>{actionFactory.getDisplayName(context)}</h4> </EuiText> </EuiFlexItem> {showDeselect && ( <EuiFlexItem grow={false}> - <EuiButtonEmpty size="s" onClick={() => onDeselect()}> + <EuiButtonEmpty size="xs" onClick={() => onDeselect()}> {txtChangeButton} </EuiButtonEmpty> </EuiFlexItem> @@ -151,10 +140,11 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({ </header> <EuiSpacer size="m" /> <div> - {actionFactory.wizard({ - config, - onConfig: onConfigChange, - })} + <actionFactory.ReactCollectConfig + config={config} + onConfig={onConfigChange} + context={context} + /> </div> </div> ); @@ -162,14 +152,16 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({ 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<ActionFactorySelectorProps> = ({ 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<ActionFactorySelectorProps> = ({ return <div>No action factories to pick from</div>; } + // 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 ( - <EuiFlexGroup wrap> - {actionFactories.map(actionFactory => ( - <EuiKeyPadMenuItem - className="auaActionWizard__actionFactoryItem" - key={actionFactory.type} - label={actionFactory.displayName} - data-testid={TEST_SUBJ_ACTION_FACTORY_ITEM} - data-test-subj={TEST_SUBJ_ACTION_FACTORY_ITEM} - onClick={() => onActionFactorySelected(actionFactory)} - > - {actionFactory.iconType && <EuiIcon type={actionFactory.iconType} size="m" />} - </EuiKeyPadMenuItem> - ))} + <EuiFlexGroup gutterSize="m" wrap={true} style={firefoxBugFix}> + {[...actionFactories] + .sort((f1, f2) => f2.order - f1.order) + .map(actionFactory => ( + <EuiFlexItem grow={false} key={actionFactory.id}> + <EuiKeyPadMenuItem + className="auaActionWizard__actionFactoryItem" + label={actionFactory.getDisplayName(context)} + data-test-subj={`${TEST_SUBJ_ACTION_FACTORY_ITEM}-${actionFactory.id}`} + onClick={() => onActionFactorySelected(actionFactory)} + > + {actionFactory.getIconType(context) && ( + <EuiIcon type={actionFactory.getIconType(context)!} size="m" /> + )} + </EuiKeyPadMenuItem> + </EuiFlexItem> + ))} </EuiFlexGroup> ); }; 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 641f25176264a..a315184bf68ef 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 ed224248ec4cd..a189afbf956ee 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 8ecdde681069e..c3e749f163c94 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<DashboardDrilldownConfig>) { + const config = props.config ?? { + dashboardId: undefined, + useCurrentFilters: true, + useCurrentDateRange: true, + }; + return ( + <> + <EuiFormRow label="Choose destination dashboard:"> + <EuiSelect + name="selectDashboard" + hasNoInitialSelection={true} + options={dashboards.map(({ id, title }) => ({ value: id, text: title }))} + value={config.dashboardId} + onChange={e => { + props.onConfig({ ...config, dashboardId: e.target.value }); + }} + /> + </EuiFormRow> + <EuiFormRow hasChildLabel={false}> + <EuiSwitch + name="useCurrentFilters" + label="Use current dashboard's filters" + checked={config.useCurrentFilters} + onChange={() => + props.onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + /> + </EuiFormRow> + <EuiFormRow hasChildLabel={false}> + <EuiSwitch + name="useCurrentDateRange" + label="Use current dashboard's date range" + checked={config.useCurrentDateRange} + onChange={() => + props.onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + </EuiFormRow> + </> + ); +} + +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 ( - <> - <EuiFormRow label="Choose destination dashboard:"> - <EuiSelect - name="selectDashboard" - hasNoInitialSelection={true} - options={dashboards.map(({ id, title }) => ({ value: id, text: title }))} - value={config.dashboardId} - onChange={e => { - props.onConfig({ ...config, dashboardId: e.target.value }); - }} - /> - </EuiFormRow> - <EuiFormRow hasChildLabel={false}> - <EuiSwitch - name="useCurrentFilters" - label="Use current dashboard's filters" - checked={config.useCurrentDashboardFilters} - onChange={() => - props.onConfig({ - ...config, - useCurrentDashboardFilters: !config.useCurrentDashboardFilters, - }) - } - /> - </EuiFormRow> - <EuiFormRow hasChildLabel={false}> - <EuiSwitch - name="useCurrentDateRange" - label="Use current dashboard's date range" - checked={config.useCurrentDashboardDataRange} - onChange={() => - props.onConfig({ - ...config, - useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange, - }) - } - /> - </EuiFormRow> - </> - ); + CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig), + + isCompatible(context?: object): Promise<boolean> { + 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<UrlDrilldownConfig>) { + const config = props.config ?? { + url: '', + openInNewTab: false, + }; + return ( + <> + <EuiFormRow label="Enter target URL"> + <EuiFieldText + placeholder="Enter URL" + name="url" + value={config.url} + onChange={event => props.onConfig({ ...config, url: event.target.value })} + /> + </EuiFormRow> + <EuiFormRow hasChildLabel={false}> + <EuiSwitch + name="openInNewTab" + label="Open in new tab?" + checked={config.openInNewTab} + onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + </EuiFormRow> + </> + ); +} +export const urlDrilldownActionFactory: ActionFactoryDefinition<UrlDrilldownConfig> = { + 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 ( - <> - <EuiFormRow label="Enter target URL"> - <EuiFieldText - placeholder="Enter URL" - name="url" - value={config.url} - onChange={event => props.onConfig({ ...config, url: event.target.value })} - /> - </EuiFormRow> - <EuiFormRow hasChildLabel={false}> - <EuiSwitch - name="openInNewTab" - label="Open in new tab?" - checked={config.openInNewTab} - onChange={() => props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - </EuiFormRow> - </> - ); + CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig), + + order: 10, + isCompatible(context?: object): Promise<boolean> { + return Promise.resolve(true); }, + create: () => null as any, }; +export const urlFactory = new ActionFactory(urlDrilldownActionFactory); + export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory<any>> }) { const [state, setState] = useState<{ currentActionFactory?: ActionFactory; @@ -157,14 +194,15 @@ export function Demo({ actionFactories }: { actionFactories: Array<ActionFactory changeActionFactory(newActionFactory); }} currentActionFactory={state.currentActionFactory} + context={{}} /> <div style={{ marginTop: '44px' }} /> <hr /> - <div>Action Factory Type: {state.currentActionFactory?.type}</div> + <div>Action Factory Id: {state.currentActionFactory?.id}</div> <div>Action Factory Config: {JSON.stringify(state.config)}</div> <div> Is config valid:{' '} - {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)} + {JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)} </div> </> ); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/index.ts b/x-pack/plugins/advanced_ui_actions/public/components/index.ts new file mode 100644 index 0000000000000..236b1a6ec4611 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/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 './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 325a5ddc10179..c0cd8d5540db2 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<typeof CUSTOM_TIME_RA private dateFormat?: string; private commonlyUsedRanges: CommonlyUsedRange[]; public readonly id = CUSTOM_TIME_RANGE; - public order = 7; + public order = 30; constructor({ openModal, diff --git a/x-pack/plugins/advanced_ui_actions/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/advanced_ui_actions/public/drilldowns/drilldown_definition.ts new file mode 100644 index 0000000000000..16c8077d727cb --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/drilldowns/drilldown_definition.ts @@ -0,0 +1,100 @@ +/* + * 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 { ActionFactoryDefinition } from '../dynamic_actions'; + +/** + * This is a convenience interface to register a drilldown. Drilldown has + * ability to collect configuration from user. Once drilldown is executed it + * receives the collected information together with the context of the + * user's interaction. + * + * `Config` is a serializable object containing the configuration that the + * drilldown is able to collect using UI. + * + * `PlaceContext` is an object that the app that opens drilldown management + * flyout provides to the React component, specifying the contextual information + * about that app. For example, on Dashboard app this context contains + * information about the current embeddable and dashboard. + * + * `ExecutionContext` is an object created in response to user's interaction + * and provided to the `execute` function of the drilldown. This object contains + * information about the action user performed. + */ +export interface DrilldownDefinition< + Config extends object = object, + ExecutionContext extends object = object +> { + /** + * 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<Config, object, ExecutionContext>['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<Config>; + * + * const ReactCollectConfig: React.FC<Props> = () => { + * return <div>Collecting config...'</div>; + * }; + * + * export const CollectConfig = uiToReactComponent(ReactCollectConfig); + * ``` + */ + CollectConfig: ActionFactoryDefinition<Config, object, ExecutionContext>['CollectConfig']; + + /** + * A validator function for the config object. Should always return a boolean + * given any input. + */ + isConfigValid: ActionFactoryDefinition<Config, object, ExecutionContext>['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<string | undefined>; +} diff --git a/x-pack/plugins/advanced_ui_actions/public/drilldowns/index.ts b/x-pack/plugins/advanced_ui_actions/public/drilldowns/index.ts new file mode 100644 index 0000000000000..7f81a68c803eb --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/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 './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 0000000000000..f1aef5deff49e --- /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<Presentable<FactoryContext>, 'getHref'>, Configurable<Config, FactoryContext> { + constructor( + protected readonly def: ActionFactoryDefinition<Config, FactoryContext, ActionContext> + ) {} + + 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<boolean> { + if (!this.def.isCompatible) return true; + return await this.def.isCompatible(context); + } + + public create( + serializedAction: Omit<SerializedAction<Config>, 'factoryId'> + ): ActionDefinition<ActionContext> { + 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 0000000000000..d3751fe811665 --- /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<Omit<Presentable<FactoryContext>, 'getHref'>>, + Configurable<Config, FactoryContext> { + /** + * 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<SerializedAction<Config>, 'factoryId'> + ): ActionDefinition<ActionContext>; +} 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 0000000000000..b7f1b36f8f358 --- /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<string, ActionInternal>(); + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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<unknown> = { + 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 0000000000000..df214bfe80cc7 --- /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: <C = unknown>(context: C) => Promise<boolean>; +} + +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<object>), + 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<State> = 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<unknown>, + triggers: Array<keyof TriggerContextMapping> + ) { + 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<unknown>, + triggers: Array<keyof TriggerContextMapping> + ) { + 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 0000000000000..61e8604baa913 --- /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 0000000000000..e40441e67f033 --- /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<void>; + update(event: SerializedEvent): Promise<void>; + remove(eventId: string): Promise<void>; + read(eventId: string): Promise<SerializedEvent>; + count(): Promise<number>; + list(): Promise<SerializedEvent[]>; + + /** + * Triggered every time events changed in storage and should be re-loaded. + */ + readonly reload$?: Observable<void>; +} + +export abstract class AbstractActionStorage implements ActionStorage { + public readonly reload$: Observable<void> & Pick<Subject<void>, 'next'> = new Subject<void>(); + + public async count(): Promise<number> { + return (await this.list()).length; + } + + public async read(eventId: string): Promise<SerializedEvent> { + 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<void>; + abstract update(event: SerializedEvent): Promise<void>; + abstract remove(eventId: string): Promise<void>; + abstract list(): Promise<SerializedEvent[]>; +} + +/** + * 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 0000000000000..bb37cf5e69535 --- /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 0000000000000..9148d1ec7055a --- /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<Config> { + 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<unknown>; +} diff --git a/x-pack/plugins/advanced_ui_actions/public/index.ts b/x-pack/plugins/advanced_ui_actions/public/index.ts index c11c1119a9b13..024cfe5530b97 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 0000000000000..65fde12755beb --- /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<AdvancedUiActionsSetup>; +export type Start = jest.Mocked<AdvancedUiActionsStart>; + +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 b9f0ce43d3cdc..f042130158aec 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<UiActionsServiceEnhancements, 'registerDrilldown'> {} + +export interface StartContract + extends UiActionsStart, + Pick<UiActionsServiceEnhancements, 'getActionFactory' | 'getActionFactories'> {} 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<Setup, Start, SetupDependencies, StartDependencies> { + implements Plugin<SetupContract, StartContract, SetupDependencies, StartDependencies> { + 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 0000000000000..71a3429800c43 --- /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 0000000000000..3137e35a2fe47 --- /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 0000000000000..8befbf43d3c6a --- /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<Config, FactoryContext, ActionContext> + ) => { + if (this.actionFactories.has(definition.id)) { + throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`); + } + + const actionFactory = new ActionFactory<Config, FactoryContext, ActionContext>(definition); + + this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory<any, any, any>); + }; + + 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<Config, ExecutionContext>): void => { + const actionFactory: ActionFactoryDefinition<Config, object, ExecutionContext> = { + 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<Config, object, ExecutionContext>; + + 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 313b09535b196..5c960192dcaff 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<string, ActionFactory>; diff --git a/x-pack/legacy/plugins/apm/CONTRIBUTING.md b/x-pack/plugins/apm/CONTRIBUTING.md similarity index 100% rename from x-pack/legacy/plugins/apm/CONTRIBUTING.md rename to x-pack/plugins/apm/CONTRIBUTING.md 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 d6520ae150539..cd64b3025a65b 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<AmountAndUnit, 'amount'> & { 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 a0b1d5015b9ef..e903a56486b6e 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<string, t.Type<any, string, unknown>> + Record<string, SettingValidation> >((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 596037645c002..4d786605b00c7 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 d189fab89ae5d..9f49527438b49 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<string, string, unknown>( - '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<string, string, unknown>( + '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 98d0cb5f028c3..ebfd9d9a72704 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 b691276854fb0..cede5ed262558 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<string, string, unknown>( '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 0000000000000..82fb8ee068b30 --- /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 0000000000000..4aa166f84bfe9 --- /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<string, string, unknown>( + '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 0000000000000..5bd0fcb80c4dd --- /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 ef7fbeed4331e..a0395a4a140d9 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 6dbf175c8b4ce..adb91992f756a 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<string, string, unknown>( '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 ece229ca162fb..0000000000000 --- 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 f1890c9851a3d..0000000000000 --- 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<string, string, unknown>( - '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 ea706be9f584a..4f5763dcde582 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 7477238ba79ae..4ade59d489040 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 8786a94be096d..7869cd7d79e17 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 2e10c74378549..bc8f19becf053 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 815b8cb3d4e83..85a454b5f256a 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<any, string, unknown>; + 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<any, string, unknown>; - - /** - * 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<RawSettingDefinition['validation']>; + /** + * runtime validation of input + */ + validation: SettingValidation; }; diff --git a/x-pack/plugins/apm/common/apm_saved_object_constants.ts b/x-pack/plugins/apm/common/apm_saved_object_constants.ts index 0529d90fe940a..eb16db7715fed 100644 --- a/x-pack/plugins/apm/common/apm_saved_object_constants.ts +++ b/x-pack/plugins/apm/common/apm_saved_object_constants.ts @@ -5,7 +5,7 @@ */ // the types have to match the names of the saved object mappings -// in /x-pack/legacy/plugins/apm/mappings.json +// in /x-pack/plugins/apm/mappings.json // APM indices export const APM_INDICES_SAVED_OBJECT_TYPE = 'apm-indices'; diff --git a/x-pack/plugins/apm/common/ml_job_constants.test.ts b/x-pack/plugins/apm/common/ml_job_constants.test.ts index 2aa50a305f7c8..4941925939afb 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.test.ts +++ b/x-pack/plugins/apm/common/ml_job_constants.test.ts @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getMlIndex, getMlJobId, getMlPrefix } from './ml_job_constants'; +import { + getMlIndex, + getMlJobId, + getMlPrefix, + getMlJobServiceName, + getSeverity, + severity +} from './ml_job_constants'; describe('ml_job_constants', () => { it('getMlPrefix', () => { @@ -38,4 +45,44 @@ describe('ml_job_constants', () => { '.ml-anomalies-myservicename-mytransactiontype-high_mean_response_time' ); }); + + describe('getMlJobServiceName', () => { + it('extracts the service name from a job id', () => { + expect( + getMlJobServiceName('opbeans-node-request-high_mean_response_time') + ).toEqual('opbeans-node'); + }); + }); + + describe('getSeverity', () => { + describe('when score is undefined', () => { + it('returns undefined', () => { + expect(getSeverity(undefined)).toEqual(undefined); + }); + }); + + describe('when score < 25', () => { + it('returns warning', () => { + expect(getSeverity(10)).toEqual(severity.warning); + }); + }); + + describe('when score is between 25 and 50', () => { + it('returns minor', () => { + expect(getSeverity(40)).toEqual(severity.minor); + }); + }); + + describe('when score is between 50 and 75', () => { + it('returns major', () => { + expect(getSeverity(60)).toEqual(severity.major); + }); + }); + + describe('when score is 75 or more', () => { + it('returns critical', () => { + expect(getSeverity(100)).toEqual(severity.critical); + }); + }); + }); }); diff --git a/x-pack/plugins/apm/common/ml_job_constants.ts b/x-pack/plugins/apm/common/ml_job_constants.ts index 01f5762e2dc4b..afe0550721716 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.ts +++ b/x-pack/plugins/apm/common/ml_job_constants.ts @@ -4,6 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +export enum severity { + critical = 'critical', + major = 'major', + minor = 'minor', + warning = 'warning' +} + export function getMlPrefix(serviceName: string, transactionType?: string) { const maybeTransactionType = transactionType ? `${transactionType}-` : ''; return encodeForMlApi(`${serviceName}-${maybeTransactionType}`); @@ -13,6 +20,13 @@ export function getMlJobId(serviceName: string, transactionType?: string) { return `${getMlPrefix(serviceName, transactionType)}high_mean_response_time`; } +export function getMlJobServiceName(jobId: string) { + return jobId + .split('-') + .slice(0, -2) + .join('-'); +} + export function getMlIndex(serviceName: string, transactionType?: string) { return `.ml-anomalies-${getMlJobId(serviceName, transactionType)}`; } @@ -20,3 +34,19 @@ export function getMlIndex(serviceName: string, transactionType?: string) { export function encodeForMlApi(value: string) { return value.replace(/\s+/g, '_').toLowerCase(); } + +export function getSeverity(score?: number) { + if (typeof score !== 'number') { + return undefined; + } else if (score < 25) { + return severity.warning; + } else if (score >= 25 && score < 50) { + return severity.minor; + } else if (score >= 50 && score < 75) { + return severity.major; + } else if (score >= 75) { + return severity.critical; + } else { + return undefined; + } +} diff --git a/x-pack/legacy/plugins/apm/public/utils/pickKeys.ts b/x-pack/plugins/apm/common/utils/pick_keys.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/pickKeys.ts rename to x-pack/plugins/apm/common/utils/pick_keys.ts diff --git a/x-pack/legacy/plugins/apm/dev_docs/github_commands.md b/x-pack/plugins/apm/dev_docs/github_commands.md similarity index 100% rename from x-pack/legacy/plugins/apm/dev_docs/github_commands.md rename to x-pack/plugins/apm/dev_docs/github_commands.md diff --git a/x-pack/legacy/plugins/apm/dev_docs/typescript.md b/x-pack/plugins/apm/dev_docs/typescript.md similarity index 83% rename from x-pack/legacy/plugins/apm/dev_docs/typescript.md rename to x-pack/plugins/apm/dev_docs/typescript.md index 6858e93ec09e0..6de61b665a1b1 100644 --- a/x-pack/legacy/plugins/apm/dev_docs/typescript.md +++ b/x-pack/plugins/apm/dev_docs/typescript.md @@ -4,8 +4,8 @@ Kibana and X-Pack are very large TypeScript projects, and it comes at a cost. Ed To run the optimization: -`$ node x-pack/legacy/plugins/apm/scripts/optimize-tsconfig` +`$ node x-pack/plugins/apm/scripts/optimize-tsconfig` To undo the optimization: -`$ node x-pack/legacy/plugins/apm/scripts/unoptimize-tsconfig` +`$ node x-pack/plugins/apm/scripts/unoptimize-tsconfig` diff --git a/x-pack/legacy/plugins/apm/dev_docs/vscode_setup.md b/x-pack/plugins/apm/dev_docs/vscode_setup.md similarity index 83% rename from x-pack/legacy/plugins/apm/dev_docs/vscode_setup.md rename to x-pack/plugins/apm/dev_docs/vscode_setup.md index e1901b3855f73..1c80d1476520d 100644 --- a/x-pack/legacy/plugins/apm/dev_docs/vscode_setup.md +++ b/x-pack/plugins/apm/dev_docs/vscode_setup.md @@ -1,6 +1,6 @@ ### Visual Studio Code -When using [Visual Studio Code](https://code.visualstudio.com/) with APM it's best to set up a [multi-root workspace](https://code.visualstudio.com/docs/editor/multi-root-workspaces) and add the `x-pack/legacy/plugins/apm` directory, the `x-pack` directory, and the root of the Kibana repository to the workspace. This makes it so you can navigate and search within APM and use the wider workspace roots when you need to widen your search. +When using [Visual Studio Code](https://code.visualstudio.com/) with APM it's best to set up a [multi-root workspace](https://code.visualstudio.com/docs/editor/multi-root-workspaces) and add the `x-pack/plugins/apm` directory, the `x-pack` directory, and the root of the Kibana repository to the workspace. This makes it so you can navigate and search within APM and use the wider workspace roots when you need to widen your search. #### Using the Jest extension @@ -25,7 +25,7 @@ If you have a workspace configured as described above you should have: in your Workspace settings, and: ```json -"jest.pathToJest": "node scripts/jest.js --testPathPattern=legacy/plugins/apm", +"jest.pathToJest": "node scripts/jest.js --testPathPattern=plugins/apm", "jest.rootPath": "../../.." ``` @@ -40,7 +40,7 @@ To make the [VSCode debugger](https://vscode.readthedocs.io/en/latest/editor/deb "type": "node", "name": "APM Jest", "request": "launch", - "args": ["--runInBand", "--testPathPattern=legacy/plugins/apm"], + "args": ["--runInBand", "--testPathPattern=plugins/apm"], "cwd": "${workspaceFolder}/../../..", "console": "internalConsole", "internalConsoleOptions": "openOnSessionStart", diff --git a/x-pack/legacy/plugins/apm/e2e/.gitignore b/x-pack/plugins/apm/e2e/.gitignore similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/.gitignore rename to x-pack/plugins/apm/e2e/.gitignore diff --git a/x-pack/plugins/apm/e2e/README.md b/x-pack/plugins/apm/e2e/README.md new file mode 100644 index 0000000000000..b630747ac2d3e --- /dev/null +++ b/x-pack/plugins/apm/e2e/README.md @@ -0,0 +1,26 @@ +# End-To-End (e2e) Test for APM UI + +**Run E2E tests** + +```sh +x-pack/plugins/apm/e2e/run-e2e.sh +``` + +_Starts Kibana, APM Server, Elasticsearch (with sample data) and runs the tests_ + +## Reproducing CI builds + +> This process is very slow compared to the local development described above. Consider that the CI must install and configure the build tools and create a Docker image for the project to run tests in a consistent manner. + +The Jenkins CI uses a shell script to prepare Kibana: + +```shell +# Prepare and run Kibana locally +$ x-pack/plugins/apm/e2e/ci/prepare-kibana.sh +# Build Docker image for Kibana +$ docker build --tag cypress --build-arg NODE_VERSION=$(cat .node-version) x-pack/plugins/apm/e2e/ci +# Run Docker image +$ docker run --rm -t --user "$(id -u):$(id -g)" \ + -v `pwd`:/app --network="host" \ + --name cypress cypress +``` diff --git a/x-pack/legacy/plugins/apm/e2e/ci/Dockerfile b/x-pack/plugins/apm/e2e/ci/Dockerfile similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/ci/Dockerfile rename to x-pack/plugins/apm/e2e/ci/Dockerfile diff --git a/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh b/x-pack/plugins/apm/e2e/ci/entrypoint.sh similarity index 90% rename from x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh rename to x-pack/plugins/apm/e2e/ci/entrypoint.sh index ae5155d966e58..3349aa74dadb9 100755 --- a/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh +++ b/x-pack/plugins/apm/e2e/ci/entrypoint.sh @@ -21,9 +21,9 @@ npm config set cache ${HOME} # --exclude=packages/ \ # --exclude=built_assets --exclude=target \ # --exclude=data /app ${HOME}/ -#cd ${HOME}/app/x-pack/legacy/plugins/apm/e2e/cypress +#cd ${HOME}/app/x-pack/plugins/apm/e2e/cypress -cd /app/x-pack/legacy/plugins/apm/e2e +cd /app/x-pack/plugins/apm/e2e ## Install dependencies for cypress CI=true npm install yarn install diff --git a/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml b/x-pack/plugins/apm/e2e/ci/kibana.e2e.yml similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml rename to x-pack/plugins/apm/e2e/ci/kibana.e2e.yml diff --git a/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh b/x-pack/plugins/apm/e2e/ci/prepare-kibana.sh similarity index 95% rename from x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh rename to x-pack/plugins/apm/e2e/ci/prepare-kibana.sh index 6df17bd51e0e8..637f8fa9b4c74 100755 --- a/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh +++ b/x-pack/plugins/apm/e2e/ci/prepare-kibana.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -e -E2E_DIR="x-pack/legacy/plugins/apm/e2e" +E2E_DIR="x-pack/plugins/apm/e2e" echo "1/3 Install dependencies ..." # shellcheck disable=SC1091 diff --git a/x-pack/legacy/plugins/apm/e2e/cypress.json b/x-pack/plugins/apm/e2e/cypress.json similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress.json rename to x-pack/plugins/apm/e2e/cypress.json diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/fixtures/example.json b/x-pack/plugins/apm/e2e/cypress/fixtures/example.json similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress/fixtures/example.json rename to x-pack/plugins/apm/e2e/cypress/fixtures/example.json diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature b/x-pack/plugins/apm/e2e/cypress/integration/apm.feature similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature rename to x-pack/plugins/apm/e2e/cypress/integration/apm.feature diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts b/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts rename to x-pack/plugins/apm/e2e/cypress/integration/helpers.ts diff --git a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js new file mode 100644 index 0000000000000..a462f4a504145 --- /dev/null +++ b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js @@ -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. + */ + +module.exports = { + APM: { + 'Transaction duration charts': { + '1': '500 ms', + '2': '250 ms', + '3': '0 ms' + } + }, + __version: '4.2.0' +}; diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/plugins/index.js b/x-pack/plugins/apm/e2e/cypress/plugins/index.js similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress/plugins/index.js rename to x-pack/plugins/apm/e2e/cypress/plugins/index.js diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/support/commands.js b/x-pack/plugins/apm/e2e/cypress/support/commands.js similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress/support/commands.js rename to x-pack/plugins/apm/e2e/cypress/support/commands.js diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/support/index.ts b/x-pack/plugins/apm/e2e/cypress/support/index.ts similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress/support/index.ts rename to x-pack/plugins/apm/e2e/cypress/support/index.ts diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts rename to x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/typings/index.d.ts b/x-pack/plugins/apm/e2e/cypress/typings/index.d.ts similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress/typings/index.d.ts rename to x-pack/plugins/apm/e2e/cypress/typings/index.d.ts diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js b/x-pack/plugins/apm/e2e/cypress/webpack.config.js similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js rename to x-pack/plugins/apm/e2e/cypress/webpack.config.js diff --git a/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js b/x-pack/plugins/apm/e2e/ingest-data/replay.js similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js rename to x-pack/plugins/apm/e2e/ingest-data/replay.js diff --git a/x-pack/legacy/plugins/apm/e2e/package.json b/x-pack/plugins/apm/e2e/package.json similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/package.json rename to x-pack/plugins/apm/e2e/package.json diff --git a/x-pack/legacy/plugins/apm/e2e/run-e2e.sh b/x-pack/plugins/apm/e2e/run-e2e.sh similarity index 98% rename from x-pack/legacy/plugins/apm/e2e/run-e2e.sh rename to x-pack/plugins/apm/e2e/run-e2e.sh index 7c17c14dc9601..818d45abb0e65 100755 --- a/x-pack/legacy/plugins/apm/e2e/run-e2e.sh +++ b/x-pack/plugins/apm/e2e/run-e2e.sh @@ -27,7 +27,7 @@ cd ${E2E_DIR} # Ask user to start Kibana ################################################## echo "\n${bold}To start Kibana please run the following command:${normal} -node ./scripts/kibana --no-base-path --dev --no-dev-config --config x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml" +node ./scripts/kibana --no-base-path --dev --no-dev-config --config x-pack/plugins/apm/e2e/ci/kibana.e2e.yml" # # Create tmp folder diff --git a/x-pack/legacy/plugins/apm/e2e/tsconfig.json b/x-pack/plugins/apm/e2e/tsconfig.json similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/tsconfig.json rename to x-pack/plugins/apm/e2e/tsconfig.json diff --git a/x-pack/legacy/plugins/apm/e2e/yarn.lock b/x-pack/plugins/apm/e2e/yarn.lock similarity index 100% rename from x-pack/legacy/plugins/apm/e2e/yarn.lock rename to x-pack/plugins/apm/e2e/yarn.lock diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 7ffdb676c740f..85e3761129018 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -1,13 +1,24 @@ { "id": "apm", - "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": [ - "xpack", - "apm" + "requiredPlugins": [ + "features", + "apm_oss", + "data", + "home", + "licensing", + "triggers_actions_ui" + ], + "optionalPlugins": [ + "cloud", + "usageCollection", + "taskManager", + "actions", + "alerting", + "security" ], - "ui": false, - "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection", "taskManager","actions", "alerting"] + "server": true, + "ui": true, + "configPath": ["xpack", "apm"] } diff --git a/x-pack/plugins/apm/public/application/index.tsx b/x-pack/plugins/apm/public/application/index.tsx new file mode 100644 index 0000000000000..314bd722274de --- /dev/null +++ b/x-pack/plugins/apm/public/application/index.tsx @@ -0,0 +1,124 @@ +/* + * 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 { ApmRoute } from '@elastic/apm-rum-react'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Route, Router, Switch } from 'react-router-dom'; +import styled from 'styled-components'; +import { CoreStart, AppMountParameters } from '../../../../../src/core/public'; +import { ApmPluginSetupDeps } from '../plugin'; +import { ApmPluginContext } from '../context/ApmPluginContext'; +import { LicenseProvider } from '../context/LicenseContext'; +import { LoadingIndicatorProvider } from '../context/LoadingIndicatorContext'; +import { LocationProvider } from '../context/LocationContext'; +import { MatchedRouteProvider } from '../context/MatchedRouteContext'; +import { UrlParamsProvider } from '../context/UrlParamsContext'; +import { AlertsContextProvider } from '../../../triggers_actions_ui/public'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; +import { px, unit, units } from '../style/variables'; +import { UpdateBreadcrumbs } from '../components/app/Main/UpdateBreadcrumbs'; +import { APMIndicesPermission } from '../components/app/APMIndicesPermission'; +import { ScrollToTopOnPathChange } from '../components/app/Main/ScrollToTopOnPathChange'; +import { routes } from '../components/app/Main/route_config'; +import { history } from '../utils/history'; +import { ConfigSchema } from '..'; +import 'react-vis/dist/style.css'; + +const MainContainer = styled.div` + min-width: ${px(unit * 50)}; + padding: ${px(units.plus)}; + height: 100%; +`; + +const App = () => { + return ( + <MainContainer data-test-subj="apmMainContainer" role="main"> + <UpdateBreadcrumbs routes={routes} /> + <Route component={ScrollToTopOnPathChange} /> + <APMIndicesPermission> + <Switch> + {routes.map((route, i) => ( + <ApmRoute key={i} {...route} /> + ))} + </Switch> + </APMIndicesPermission> + </MainContainer> + ); +}; + +const ApmAppRoot = ({ + core, + deps, + routerHistory, + config +}: { + core: CoreStart; + deps: ApmPluginSetupDeps; + routerHistory: typeof history; + config: ConfigSchema; +}) => { + const i18nCore = core.i18n; + const plugins = deps; + const apmPluginContextValue = { + config, + core, + plugins + }; + return ( + <ApmPluginContext.Provider value={apmPluginContextValue}> + <AlertsContextProvider + value={{ + http: core.http, + docLinks: core.docLinks, + capabilities: core.application.capabilities, + toastNotifications: core.notifications.toasts, + actionTypeRegistry: plugins.triggers_actions_ui.actionTypeRegistry, + alertTypeRegistry: plugins.triggers_actions_ui.alertTypeRegistry + }} + > + <KibanaContextProvider services={{ ...core, ...plugins }}> + <i18nCore.Context> + <Router history={routerHistory}> + <LocationProvider> + <MatchedRouteProvider routes={routes}> + <UrlParamsProvider> + <LoadingIndicatorProvider> + <LicenseProvider> + <App /> + </LicenseProvider> + </LoadingIndicatorProvider> + </UrlParamsProvider> + </MatchedRouteProvider> + </LocationProvider> + </Router> + </i18nCore.Context> + </KibanaContextProvider> + </AlertsContextProvider> + </ApmPluginContext.Provider> + ); +}; + +/** + * This module is rendered asynchronously in the Kibana platform. + */ +export const renderApp = ( + core: CoreStart, + deps: ApmPluginSetupDeps, + { element }: AppMountParameters, + config: ConfigSchema +) => { + ReactDOM.render( + <ApmAppRoot + core={core} + deps={deps} + routerHistory={history} + config={config} + />, + element + ); + return () => ReactDOM.unmountComponentAtNode(element); +}; 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/APMIndicesPermission/index.test.tsx b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.test.tsx new file mode 100644 index 0000000000000..b3f90fd9aee34 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.test.tsx @@ -0,0 +1,142 @@ +/* + * 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, fireEvent, act } from '@testing-library/react'; +import { shallow } from 'enzyme'; +import { APMIndicesPermission } from './'; + +import * as hooks from '../../../hooks/useFetcher'; +import { + expectTextsInDocument, + expectTextsNotInDocument +} from '../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; + +describe('APMIndicesPermission', () => { + it('returns empty component when api status is loading', () => { + spyOn(hooks, 'useFetcher').and.returnValue({ + status: hooks.FETCH_STATUS.LOADING + }); + const component = shallow(<APMIndicesPermission />); + expect(component.isEmptyRender()).toBeTruthy(); + }); + it('returns empty component when api status is pending', () => { + spyOn(hooks, 'useFetcher').and.returnValue({ + status: hooks.FETCH_STATUS.PENDING + }); + const component = shallow(<APMIndicesPermission />); + expect(component.isEmptyRender()).toBeTruthy(); + }); + it('renders missing permission page', () => { + spyOn(hooks, 'useFetcher').and.returnValue({ + status: hooks.FETCH_STATUS.SUCCESS, + data: { + has_all_requested: false, + index: { + 'apm-*': { read: false } + } + } + }); + const component = render( + <MockApmPluginContextWrapper> + <APMIndicesPermission /> + </MockApmPluginContextWrapper> + ); + expectTextsInDocument(component, [ + 'Missing permissions to access APM', + 'Dismiss', + 'apm-*' + ]); + }); + + it('shows children component when no index is returned', () => { + spyOn(hooks, 'useFetcher').and.returnValue({ + status: hooks.FETCH_STATUS.SUCCESS, + data: { + has_all_requested: false, + index: {} + } + }); + const component = render( + <MockApmPluginContextWrapper> + <APMIndicesPermission> + <p>My amazing component</p> + </APMIndicesPermission> + </MockApmPluginContextWrapper> + ); + expectTextsNotInDocument(component, ['Missing permissions to access APM']); + expectTextsInDocument(component, ['My amazing component']); + }); + + it('shows children component when indices have read privileges', () => { + spyOn(hooks, 'useFetcher').and.returnValue({ + status: hooks.FETCH_STATUS.SUCCESS, + data: { + has_all_requested: true, + index: {} + } + }); + const component = render( + <MockApmPluginContextWrapper> + <APMIndicesPermission> + <p>My amazing component</p> + </APMIndicesPermission> + </MockApmPluginContextWrapper> + ); + expectTextsNotInDocument(component, ['Missing permissions to access APM']); + expectTextsInDocument(component, ['My amazing component']); + }); + + it('dismesses the warning by clicking on the escape hatch', () => { + spyOn(hooks, 'useFetcher').and.returnValue({ + status: hooks.FETCH_STATUS.SUCCESS, + data: { + has_all_requested: false, + index: { + 'apm-error-*': { read: false }, + 'apm-trasanction-*': { read: false }, + 'apm-metrics-*': { read: true }, + 'apm-span-*': { read: true } + } + } + }); + const component = render( + <MockApmPluginContextWrapper> + <APMIndicesPermission> + <p>My amazing component</p> + </APMIndicesPermission> + </MockApmPluginContextWrapper> + ); + expectTextsInDocument(component, [ + 'Dismiss', + 'apm-error-*', + 'apm-trasanction-*' + ]); + act(() => { + fireEvent.click(component.getByText('Dismiss')); + }); + expectTextsInDocument(component, ['My amazing component']); + }); + + it("shows children component when api doesn't return value", () => { + spyOn(hooks, 'useFetcher').and.returnValue({}); + const component = render( + <MockApmPluginContextWrapper> + <APMIndicesPermission> + <p>My amazing component</p> + </APMIndicesPermission> + </MockApmPluginContextWrapper> + ); + expectTextsNotInDocument(component, [ + 'Missing permissions to access APM', + 'apm-7.5.1-error-*', + 'apm-7.5.1-metric-*', + 'apm-7.5.1-transaction-*', + 'apm-7.5.1-span-*' + ]); + expectTextsInDocument(component, ['My amazing component']); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx new file mode 100644 index 0000000000000..9074726f76e6d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx @@ -0,0 +1,167 @@ +/* + * 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 { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPanel, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { isEmpty } from 'lodash'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; +import { fontSize, pct, px, units } from '../../../style/variables'; +import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; +import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink'; + +export const APMIndicesPermission: React.FC = ({ children }) => { + const [ + isPermissionWarningDismissed, + setIsPermissionWarningDismissed + ] = useState(false); + + const { data: indicesPrivileges, status } = useFetcher(callApmApi => { + return callApmApi({ + pathname: '/api/apm/security/indices_privileges' + }); + }, []); + + // Return null until receive the reponse of the api. + if (status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING) { + return null; + } + + // Show permission warning when a user has at least one index without Read privilege, + // and they have not manually dismissed the warning + if ( + indicesPrivileges && + !indicesPrivileges.has_all_requested && + !isEmpty(indicesPrivileges.index) && + !isPermissionWarningDismissed + ) { + const indicesWithoutPermission = Object.keys( + indicesPrivileges.index + ).filter(index => !indicesPrivileges.index[index].read); + return ( + <PermissionWarning + indicesWithoutPermission={indicesWithoutPermission} + onEscapeHatchClick={() => setIsPermissionWarningDismissed(true)} + /> + ); + } + + return <>{children}</>; +}; + +const CentralizedContainer = styled.div` + height: ${pct(100)}; + display: flex; + justify-content: center; + align-items: center; +`; + +const EscapeHatch = styled.div` + width: ${pct(100)}; + margin-top: ${px(units.plus)}; + justify-content: center; + display: flex; +`; + +interface Props { + indicesWithoutPermission: string[]; + onEscapeHatchClick: () => void; +} + +const PermissionWarning = ({ + indicesWithoutPermission, + onEscapeHatchClick +}: Props) => { + return ( + <div style={{ height: pct(95) }}> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <EuiTitle size="l"> + <h1> + {i18n.translate('xpack.apm.permission.apm', { + defaultMessage: 'APM' + })} + </h1> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <SetupInstructionsLink /> + </EuiFlexItem> + </EuiFlexGroup> + <CentralizedContainer> + <div> + <EuiPanel paddingSize="none"> + <EuiEmptyPrompt + iconType="apmApp" + iconColor={''} + title={ + <h2> + {i18n.translate('xpack.apm.permission.title', { + defaultMessage: 'Missing permissions to access APM' + })} + </h2> + } + body={ + <> + <p> + {i18n.translate('xpack.apm.permission.description', { + defaultMessage: + "Your user doesn't have access to all APM indices. You can still use the APM app but some data may be missing. You must be granted access to the following indices:" + })} + </p> + <ul style={{ listStyleType: 'none' }}> + {indicesWithoutPermission.map(index => ( + <li key={index} style={{ marginTop: units.half }}> + <EuiText size="s">{index}</EuiText> + </li> + ))} + </ul> + </> + } + actions={ + <> + <ElasticDocsLink + section="/apm/server" + path="/feature-roles.html" + > + {(href: string) => ( + <EuiButton color="primary" fill href={href}> + {i18n.translate('xpack.apm.permission.learnMore', { + defaultMessage: 'Learn more about APM permissions' + })} + </EuiButton> + )} + </ElasticDocsLink> + <EscapeHatch> + <EuiLink + color="subdued" + onClick={onEscapeHatchClick} + style={{ fontSize }} + > + {i18n.translate('xpack.apm.permission.dismissWarning', { + defaultMessage: 'Dismiss' + })} + </EuiLink> + </EscapeHatch> + </> + } + /> + </EuiPanel> + </div> + </CentralizedContainer> + </div> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ErrorTabs.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ErrorTabs.tsx similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ErrorTabs.tsx rename to x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ErrorTabs.tsx index 33774c941ffd6..5982346d97b89 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ErrorTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ErrorTabs.tsx @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; -import { APMError } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; +import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; export interface ErrorTab { key: 'log_stacktrace' | 'exception_stacktrace' | 'metadata'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.test.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.test.tsx rename to x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.tsx similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.tsx rename to x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.tsx index 75e518a278aea..faec93013886c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/ExceptionStacktrace.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { EuiTitle } from '@elastic/eui'; -import { Exception } from '../../../../../../../../plugins/apm/typings/es_schemas/raw/error_raw'; +import { Exception } from '../../../../../typings/es_schemas/raw/error_raw'; import { Stacktrace } from '../../../shared/Stacktrace'; import { CauseStacktrace } from '../../../shared/Stacktrace/CauseStacktrace'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/__snapshots__/index.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/__snapshots__/index.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/__snapshots__/index.test.tsx.snap rename to x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/__snapshots__/index.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx rename to x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx new file mode 100644 index 0000000000000..9e2fd776e67a3 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/index.tsx @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiButtonEmpty, + EuiPanel, + EuiSpacer, + EuiTab, + EuiTabs, + EuiTitle, + EuiIcon, + EuiToolTip +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Location } from 'history'; +import React from 'react'; +import styled from 'styled-components'; +import { first } from 'lodash'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ErrorGroupAPIResponse } from '../../../../../server/lib/errors/get_error_group'; +import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; +import { IUrlParams } from '../../../../context/UrlParamsContext/types'; +import { px, unit, units } from '../../../../style/variables'; +import { DiscoverErrorLink } from '../../../shared/Links/DiscoverLinks/DiscoverErrorLink'; +import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; +import { history } from '../../../../utils/history'; +import { ErrorMetadata } from '../../../shared/MetadataTable/ErrorMetadata'; +import { Stacktrace } from '../../../shared/Stacktrace'; +import { + ErrorTab, + exceptionStacktraceTab, + getTabs, + logStacktraceTab +} from './ErrorTabs'; +import { Summary } from '../../../shared/Summary'; +import { TimestampTooltip } from '../../../shared/TimestampTooltip'; +import { HttpInfoSummaryItem } from '../../../shared/Summary/HttpInfoSummaryItem'; +import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink'; +import { UserAgentSummaryItem } from '../../../shared/Summary/UserAgentSummaryItem'; +import { ExceptionStacktrace } from './ExceptionStacktrace'; + +const HeaderContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: ${px(unit)}; +`; + +const TransactionLinkName = styled.div` + margin-left: ${px(units.half)}; + display: inline-block; + vertical-align: middle; +`; + +interface Props { + errorGroup: ErrorGroupAPIResponse; + urlParams: IUrlParams; + location: Location; +} + +// TODO: Move query-string-based tabs into a re-usable component? +function getCurrentTab( + tabs: ErrorTab[] = [], + currentTabKey: string | undefined +) { + const selectedTab = tabs.find(({ key }) => key === currentTabKey); + return selectedTab ? selectedTab : first(tabs) || {}; +} + +export function DetailView({ errorGroup, urlParams, location }: Props) { + const { transaction, error, occurrencesCount } = errorGroup; + + if (!error) { + return null; + } + + const tabs = getTabs(error); + const currentTab = getCurrentTab(tabs, urlParams.detailTab); + + const errorUrl = error.error.page?.url || error.url?.full; + + const method = error.http?.request?.method; + const status = error.http?.response?.status_code; + + return ( + <EuiPanel> + <HeaderContainer> + <EuiTitle size="s"> + <h3> + {i18n.translate( + 'xpack.apm.errorGroupDetails.errorOccurrenceTitle', + { + defaultMessage: 'Error occurrence' + } + )} + </h3> + </EuiTitle> + <DiscoverErrorLink error={error} kuery={urlParams.kuery}> + <EuiButtonEmpty iconType="discoverApp"> + {i18n.translate( + 'xpack.apm.errorGroupDetails.viewOccurrencesInDiscoverButtonLabel', + { + defaultMessage: + 'View {occurrencesCount} {occurrencesCount, plural, one {occurrence} other {occurrences}} in Discover.', + values: { occurrencesCount } + } + )} + </EuiButtonEmpty> + </DiscoverErrorLink> + </HeaderContainer> + + <Summary + items={[ + <TimestampTooltip time={error.timestamp.us / 1000} />, + errorUrl && method ? ( + <HttpInfoSummaryItem + url={errorUrl} + method={method} + status={status} + /> + ) : null, + transaction && transaction.user_agent ? ( + <UserAgentSummaryItem {...transaction.user_agent} /> + ) : null, + transaction && ( + <EuiToolTip + content={i18n.translate( + 'xpack.apm.errorGroupDetails.relatedTransactionSample', + { + defaultMessage: 'Related transaction sample' + } + )} + > + <TransactionDetailLink + traceId={transaction.trace.id} + transactionId={transaction.transaction.id} + transactionName={transaction.transaction.name} + transactionType={transaction.transaction.type} + serviceName={transaction.service.name} + > + <EuiIcon type="merge" /> + <TransactionLinkName> + {transaction.transaction.name} + </TransactionLinkName> + </TransactionDetailLink> + </EuiToolTip> + ) + ]} + /> + + <EuiSpacer /> + + <EuiTabs> + {tabs.map(({ key, label }) => { + return ( + <EuiTab + onClick={() => { + history.replace({ + ...location, + search: fromQuery({ + ...toQuery(location.search), + detailTab: key + }) + }); + }} + isSelected={currentTab.key === key} + key={key} + > + {label} + </EuiTab> + ); + })} + </EuiTabs> + <EuiSpacer /> + <TabContent error={error} currentTab={currentTab} /> + </EuiPanel> + ); +} + +function TabContent({ + error, + currentTab +}: { + error: APMError; + currentTab: ErrorTab; +}) { + const codeLanguage = error.service.language?.name; + const exceptions = error.error.exception || []; + const logStackframes = error.error.log?.stacktrace; + + switch (currentTab.key) { + case logStacktraceTab.key: + return ( + <Stacktrace stackframes={logStackframes} codeLanguage={codeLanguage} /> + ); + case exceptionStacktraceTab.key: + return ( + <ExceptionStacktrace + codeLanguage={codeLanguage} + exceptions={exceptions} + /> + ); + default: + return <ErrorMetadata error={error} />; + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx rename to x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx new file mode 100644 index 0000000000000..c40c711a590be --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables'; +import { ApmHeader } from '../../shared/ApmHeader'; +import { DetailView } from './DetailView'; +import { ErrorDistribution } from './Distribution'; +import { useLocation } from '../../../hooks/useLocation'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useTrackPageview } from '../../../../../observability/public'; + +const Titles = styled.div` + margin-bottom: ${px(units.plus)}; +`; + +const Label = styled.div` + margin-bottom: ${px(units.quarter)}; + font-size: ${fontSizes.small}; + color: ${theme.euiColorMediumShade}; +`; + +const Message = styled.div` + font-family: ${fontFamilyCode}; + font-weight: bold; + font-size: ${fontSizes.large}; + margin-bottom: ${px(units.half)}; +`; + +const Culprit = styled.div` + font-family: ${fontFamilyCode}; +`; + +function getShortGroupId(errorGroupId?: string) { + if (!errorGroupId) { + return NOT_AVAILABLE_LABEL; + } + + return errorGroupId.slice(0, 5); +} + +export function ErrorGroupDetails() { + const location = useLocation(); + const { urlParams, uiFilters } = useUrlParams(); + const { serviceName, start, end, errorGroupId } = urlParams; + + const { data: errorGroupData } = useFetcher( + callApmApi => { + if (serviceName && start && end && errorGroupId) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/errors/{groupId}', + params: { + path: { + serviceName, + groupId: errorGroupId + }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters) + } + } + }); + } + }, + [serviceName, start, end, errorGroupId, uiFilters] + ); + + const { data: errorDistributionData } = useFetcher( + callApmApi => { + if (serviceName && start && end && errorGroupId) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/errors/distribution', + params: { + path: { + serviceName + }, + query: { + start, + end, + groupId: errorGroupId, + uiFilters: JSON.stringify(uiFilters) + } + } + }); + } + }, + [serviceName, start, end, errorGroupId, uiFilters] + ); + + useTrackPageview({ app: 'apm', path: 'error_group_details' }); + useTrackPageview({ app: 'apm', path: 'error_group_details', delay: 15000 }); + + if (!errorGroupData || !errorDistributionData) { + return null; + } + + // If there are 0 occurrences, show only distribution chart w. empty message + const showDetails = errorGroupData.occurrencesCount !== 0; + const logMessage = errorGroupData.error?.error.log?.message; + const excMessage = errorGroupData.error?.error.exception?.[0].message; + const culprit = errorGroupData.error?.error.culprit; + const isUnhandled = + errorGroupData.error?.error.exception?.[0].handled === false; + + return ( + <div> + <ApmHeader> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <EuiTitle> + <h1> + {i18n.translate('xpack.apm.errorGroupDetails.errorGroupTitle', { + defaultMessage: 'Error group {errorGroupId}', + values: { + errorGroupId: getShortGroupId(urlParams.errorGroupId) + } + })} + </h1> + </EuiTitle> + </EuiFlexItem> + {isUnhandled && ( + <EuiFlexItem grow={false}> + <EuiBadge color="warning"> + {i18n.translate('xpack.apm.errorGroupDetails.unhandledLabel', { + defaultMessage: 'Unhandled' + })} + </EuiBadge> + </EuiFlexItem> + )} + </EuiFlexGroup> + </ApmHeader> + + <EuiSpacer size="s" /> + + <EuiPanel> + {showDetails && ( + <Titles> + <EuiText> + {logMessage && ( + <Fragment> + <Label> + {i18n.translate( + 'xpack.apm.errorGroupDetails.logMessageLabel', + { + defaultMessage: 'Log message' + } + )} + </Label> + <Message>{logMessage}</Message> + </Fragment> + )} + <Label> + {i18n.translate( + 'xpack.apm.errorGroupDetails.exceptionMessageLabel', + { + defaultMessage: 'Exception message' + } + )} + </Label> + <Message>{excMessage || NOT_AVAILABLE_LABEL}</Message> + <Label> + {i18n.translate('xpack.apm.errorGroupDetails.culpritLabel', { + defaultMessage: 'Culprit' + })} + </Label> + <Culprit>{culprit || NOT_AVAILABLE_LABEL}</Culprit> + </EuiText> + </Titles> + )} + + <ErrorDistribution + distribution={errorDistributionData} + title={i18n.translate( + 'xpack.apm.errorGroupDetails.occurrencesChartLabel', + { + defaultMessage: 'Occurrences' + } + )} + /> + </EuiPanel> + <EuiSpacer size="s" /> + {showDetails && ( + <DetailView + errorGroup={errorGroupData} + urlParams={urlParams} + location={location} + /> + )} + </div> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx rename to x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap rename to x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json rename to x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx new file mode 100644 index 0000000000000..695d3463d3b3d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/index.tsx @@ -0,0 +1,199 @@ +/* + * 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 { EuiBadge, EuiToolTip } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ErrorGroupListAPIResponse } from '../../../../../server/lib/errors/get_error_groups'; +import { + fontFamilyCode, + fontSizes, + px, + truncate, + unit +} from '../../../../style/variables'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { ManagedTable } from '../../../shared/ManagedTable'; +import { ErrorDetailLink } from '../../../shared/Links/apm/ErrorDetailLink'; +import { TimestampTooltip } from '../../../shared/TimestampTooltip'; +import { ErrorOverviewLink } from '../../../shared/Links/apm/ErrorOverviewLink'; +import { APMQueryParams } from '../../../shared/Links/url_helpers'; + +const GroupIdLink = styled(ErrorDetailLink)` + font-family: ${fontFamilyCode}; +`; + +const MessageAndCulpritCell = styled.div` + ${truncate('100%')}; +`; + +const ErrorLink = styled(ErrorOverviewLink)` + ${truncate('100%')}; +`; + +const MessageLink = styled(ErrorDetailLink)` + font-family: ${fontFamilyCode}; + font-size: ${fontSizes.large}; + ${truncate('100%')}; +`; + +const Culprit = styled.div` + font-family: ${fontFamilyCode}; +`; + +interface Props { + items: ErrorGroupListAPIResponse; +} + +const ErrorGroupList: React.FC<Props> = props => { + const { items } = props; + const { urlParams } = useUrlParams(); + const { serviceName } = urlParams; + + if (!serviceName) { + throw new Error('Service name is required'); + } + + const columns = useMemo( + () => [ + { + name: i18n.translate('xpack.apm.errorsTable.groupIdColumnLabel', { + defaultMessage: 'Group ID' + }), + field: 'groupId', + sortable: false, + width: px(unit * 6), + render: (groupId: string) => { + return ( + <GroupIdLink serviceName={serviceName} errorGroupId={groupId}> + {groupId.slice(0, 5) || NOT_AVAILABLE_LABEL} + </GroupIdLink> + ); + } + }, + { + name: i18n.translate('xpack.apm.errorsTable.typeColumnLabel', { + defaultMessage: 'Type' + }), + field: 'type', + sortable: false, + render: (type: string, item: ErrorGroupListAPIResponse[0]) => { + return ( + <ErrorLink + title={type} + serviceName={serviceName} + query={ + { + ...urlParams, + kuery: `error.exception.type:${type}` + } as APMQueryParams + } + > + {type} + </ErrorLink> + ); + } + }, + { + name: i18n.translate( + 'xpack.apm.errorsTable.errorMessageAndCulpritColumnLabel', + { + defaultMessage: 'Error message and culprit' + } + ), + field: 'message', + sortable: false, + width: '50%', + render: (message: string, item: ErrorGroupListAPIResponse[0]) => { + return ( + <MessageAndCulpritCell> + <EuiToolTip + id="error-message-tooltip" + content={message || NOT_AVAILABLE_LABEL} + > + <MessageLink + serviceName={serviceName} + errorGroupId={item.groupId} + > + {message || NOT_AVAILABLE_LABEL} + </MessageLink> + </EuiToolTip> + <br /> + <EuiToolTip + id="error-culprit-tooltip" + content={item.culprit || NOT_AVAILABLE_LABEL} + > + <Culprit>{item.culprit || NOT_AVAILABLE_LABEL}</Culprit> + </EuiToolTip> + </MessageAndCulpritCell> + ); + } + }, + { + name: '', + field: 'handled', + sortable: false, + align: 'right', + render: (isUnhandled: boolean) => + isUnhandled === false && ( + <EuiBadge color="warning"> + {i18n.translate('xpack.apm.errorsTable.unhandledLabel', { + defaultMessage: 'Unhandled' + })} + </EuiBadge> + ) + }, + { + name: i18n.translate('xpack.apm.errorsTable.occurrencesColumnLabel', { + defaultMessage: 'Occurrences' + }), + field: 'occurrenceCount', + sortable: true, + dataType: 'number', + render: (value?: number) => + value ? numeral(value).format('0.[0]a') : NOT_AVAILABLE_LABEL + }, + { + field: 'latestOccurrenceAt', + sortable: true, + name: i18n.translate( + 'xpack.apm.errorsTable.latestOccurrenceColumnLabel', + { + defaultMessage: 'Latest occurrence' + } + ), + align: 'right', + render: (value?: number) => + value ? ( + <TimestampTooltip time={value} timeUnit="minutes" /> + ) : ( + NOT_AVAILABLE_LABEL + ) + } + ], + [serviceName, urlParams] + ); + + return ( + <ManagedTable + noItemsMessage={i18n.translate('xpack.apm.errorsTable.noErrorsLabel', { + defaultMessage: 'No errors were found' + })} + items={items} + columns={columns} + initialPageSize={25} + initialSortField="occurrenceCount" + initialSortDirection="desc" + sortItems={false} + /> + ); +}; + +export { ErrorGroupList }; diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx new file mode 100644 index 0000000000000..604893952d9d6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx @@ -0,0 +1,137 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; +import { ErrorGroupList } from './List'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useTrackPageview } from '../../../../../observability/public'; +import { PROJECTION } from '../../../../common/projections/typings'; +import { LocalUIFilters } from '../../shared/LocalUIFilters'; + +const ErrorGroupOverview: React.FC = () => { + const { urlParams, uiFilters } = useUrlParams(); + + const { serviceName, start, end, sortField, sortDirection } = urlParams; + + const { data: errorDistributionData } = useFetcher( + callApmApi => { + if (serviceName && start && end) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/errors/distribution', + params: { + path: { + serviceName + }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters) + } + } + }); + } + }, + [serviceName, start, end, uiFilters] + ); + + const { data: errorGroupListData } = useFetcher( + callApmApi => { + const normalizedSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; + + if (serviceName && start && end) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/errors', + params: { + path: { + serviceName + }, + query: { + start, + end, + sortField, + sortDirection: normalizedSortDirection, + uiFilters: JSON.stringify(uiFilters) + } + } + }); + } + }, + [serviceName, start, end, sortField, sortDirection, uiFilters] + ); + + useTrackPageview({ + app: 'apm', + path: 'error_group_overview' + }); + useTrackPageview({ app: 'apm', path: 'error_group_overview', delay: 15000 }); + + const localUIFiltersConfig = useMemo(() => { + const config: React.ComponentProps<typeof LocalUIFilters> = { + filterNames: ['host', 'containerId', 'podName', 'serviceVersion'], + params: { + serviceName + }, + projection: PROJECTION.ERROR_GROUPS + }; + + return config; + }, [serviceName]); + + if (!errorDistributionData || !errorGroupListData) { + return null; + } + + return ( + <> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem grow={1}> + <LocalUIFilters {...localUIFiltersConfig} /> + </EuiFlexItem> + <EuiFlexItem grow={7}> + <EuiFlexGroup> + <EuiFlexItem> + <EuiPanel> + <ErrorDistribution + distribution={errorDistributionData} + title={i18n.translate( + 'xpack.apm.serviceDetails.metrics.errorOccurrencesChartTitle', + { + defaultMessage: 'Error occurrences' + } + )} + /> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + + <EuiSpacer size="s" /> + + <EuiPanel> + <EuiTitle size="xs"> + <h3>Errors</h3> + </EuiTitle> + <EuiSpacer size="s" /> + + <ErrorGroupList items={errorGroupListData} /> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); +}; + +export { ErrorGroupOverview }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx b/x-pack/plugins/apm/public/components/app/Home/Home.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx rename to x-pack/plugins/apm/public/components/app/Home/Home.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap rename to x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 2b1f835a14f4a..9f461eeb5b6fc 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -5,7 +5,6 @@ exports[`Home component should render services 1`] = ` value={ Object { "config": Object { - "indexPatternTitle": "apm-*", "serviceMapEnabled": true, "ui": Object { "enabled": false, @@ -46,7 +45,6 @@ exports[`Home component should render traces 1`] = ` value={ Object { "config": Object { - "indexPatternTitle": "apm-*", "serviceMapEnabled": true, "ui": Object { "enabled": false, diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx rename to x-pack/plugins/apm/public/components/app/Home/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx b/x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx rename to x-pack/plugins/apm/public/components/app/Main/ProvideBreadcrumbs.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/ScrollToTopOnPathChange.tsx b/x-pack/plugins/apm/public/components/app/Main/ScrollToTopOnPathChange.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Main/ScrollToTopOnPathChange.tsx rename to x-pack/plugins/apm/public/components/app/Main/ScrollToTopOnPathChange.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx rename to x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx rename to x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap rename to x-pack/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/__test__/ProvideBreadcrumbs.test.tsx b/x-pack/plugins/apm/public/components/app/Main/__test__/ProvideBreadcrumbs.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Main/__test__/ProvideBreadcrumbs.test.tsx rename to x-pack/plugins/apm/public/components/app/Main/__test__/ProvideBreadcrumbs.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx new file mode 100644 index 0000000000000..6d1db8c5dc6d4 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -0,0 +1,253 @@ +/* + * 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'; +import React from 'react'; +import { Redirect, RouteComponentProps } from 'react-router-dom'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../../common/service_nodes'; +import { ErrorGroupDetails } from '../../ErrorGroupDetails'; +import { ServiceDetails } from '../../ServiceDetails'; +import { TransactionDetails } from '../../TransactionDetails'; +import { Home } from '../../Home'; +import { BreadcrumbRoute } from '../ProvideBreadcrumbs'; +import { RouteName } from './route_names'; +import { Settings } from '../../Settings'; +import { AgentConfigurations } from '../../Settings/AgentConfigurations'; +import { ApmIndices } from '../../Settings/ApmIndices'; +import { toQuery } from '../../../shared/Links/url_helpers'; +import { ServiceNodeMetrics } from '../../ServiceNodeMetrics'; +import { resolveUrlParams } from '../../../../context/UrlParamsContext/resolveUrlParams'; +import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../common/i18n'; +import { TraceLink } from '../../TraceLink'; +import { CustomizeUI } from '../../Settings/CustomizeUI'; +import { + EditAgentConfigurationRouteHandler, + CreateAgentConfigurationRouteHandler +} from './route_handlers/agent_configuration'; + +const metricsBreadcrumb = i18n.translate('xpack.apm.breadcrumb.metricsTitle', { + defaultMessage: 'Metrics' +}); + +interface RouteParams { + serviceName: string; +} + +const renderAsRedirectTo = (to: string) => { + return ({ location }: RouteComponentProps<RouteParams>) => ( + <Redirect + to={{ + ...location, + pathname: to + }} + /> + ); +}; + +export const routes: BreadcrumbRoute[] = [ + { + exact: true, + path: '/', + render: renderAsRedirectTo('/services'), + breadcrumb: 'APM', + name: RouteName.HOME + }, + { + exact: true, + path: '/services', + component: () => <Home tab="services" />, + breadcrumb: i18n.translate('xpack.apm.breadcrumb.servicesTitle', { + defaultMessage: 'Services' + }), + name: RouteName.SERVICES + }, + { + exact: true, + path: '/traces', + component: () => <Home tab="traces" />, + breadcrumb: i18n.translate('xpack.apm.breadcrumb.tracesTitle', { + defaultMessage: 'Traces' + }), + name: RouteName.TRACES + }, + { + exact: true, + path: '/settings', + render: renderAsRedirectTo('/settings/agent-configuration'), + breadcrumb: i18n.translate('xpack.apm.breadcrumb.listSettingsTitle', { + defaultMessage: 'Settings' + }), + name: RouteName.SETTINGS + }, + { + exact: true, + path: '/settings/apm-indices', + component: () => ( + <Settings> + <ApmIndices /> + </Settings> + ), + breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.indicesTitle', { + defaultMessage: 'Indices' + }), + name: RouteName.INDICES + }, + { + exact: true, + path: '/settings/agent-configuration', + component: () => ( + <Settings> + <AgentConfigurations /> + </Settings> + ), + breadcrumb: i18n.translate( + 'xpack.apm.breadcrumb.settings.agentConfigurationTitle', + { defaultMessage: 'Agent Configuration' } + ), + name: RouteName.AGENT_CONFIGURATION + }, + + { + exact: true, + path: '/settings/agent-configuration/create', + breadcrumb: i18n.translate( + 'xpack.apm.breadcrumb.settings.createAgentConfigurationTitle', + { defaultMessage: 'Create Agent Configuration' } + ), + name: RouteName.AGENT_CONFIGURATION_CREATE, + component: () => <CreateAgentConfigurationRouteHandler /> + }, + { + exact: true, + path: '/settings/agent-configuration/edit', + breadcrumb: i18n.translate( + 'xpack.apm.breadcrumb.settings.editAgentConfigurationTitle', + { defaultMessage: 'Edit Agent Configuration' } + ), + name: RouteName.AGENT_CONFIGURATION_EDIT, + component: () => <EditAgentConfigurationRouteHandler /> + }, + { + exact: true, + path: '/services/:serviceName', + breadcrumb: ({ match }) => match.params.serviceName, + render: (props: RouteComponentProps<RouteParams>) => + renderAsRedirectTo( + `/services/${props.match.params.serviceName}/transactions` + )(props), + name: RouteName.SERVICE + }, + // errors + { + exact: true, + path: '/services/:serviceName/errors/:groupId', + component: ErrorGroupDetails, + breadcrumb: ({ match }) => match.params.groupId, + name: RouteName.ERROR + }, + { + exact: true, + path: '/services/:serviceName/errors', + component: () => <ServiceDetails tab="errors" />, + breadcrumb: i18n.translate('xpack.apm.breadcrumb.errorsTitle', { + defaultMessage: 'Errors' + }), + name: RouteName.ERRORS + }, + // transactions + { + exact: true, + path: '/services/:serviceName/transactions', + component: () => <ServiceDetails tab="transactions" />, + breadcrumb: i18n.translate('xpack.apm.breadcrumb.transactionsTitle', { + defaultMessage: 'Transactions' + }), + name: RouteName.TRANSACTIONS + }, + // metrics + { + exact: true, + path: '/services/:serviceName/metrics', + component: () => <ServiceDetails tab="metrics" />, + breadcrumb: metricsBreadcrumb, + name: RouteName.METRICS + }, + // service nodes, only enabled for java agents for now + { + exact: true, + path: '/services/:serviceName/nodes', + component: () => <ServiceDetails tab="nodes" />, + breadcrumb: i18n.translate('xpack.apm.breadcrumb.nodesTitle', { + defaultMessage: 'JVMs' + }), + name: RouteName.SERVICE_NODES + }, + // node metrics + { + exact: true, + path: '/services/:serviceName/nodes/:serviceNodeName/metrics', + component: () => <ServiceNodeMetrics />, + breadcrumb: ({ location }) => { + const { serviceNodeName } = resolveUrlParams(location, {}); + + if (serviceNodeName === SERVICE_NODE_NAME_MISSING) { + return UNIDENTIFIED_SERVICE_NODES_LABEL; + } + + return serviceNodeName || ''; + }, + name: RouteName.SERVICE_NODE_METRICS + }, + { + exact: true, + path: '/services/:serviceName/transactions/view', + component: TransactionDetails, + breadcrumb: ({ location }) => { + const query = toQuery(location.search); + return query.transactionName as string; + }, + name: RouteName.TRANSACTION_NAME + }, + { + exact: true, + path: '/link-to/trace/:traceId', + component: TraceLink, + breadcrumb: null, + name: RouteName.LINK_TO_TRACE + }, + + { + exact: true, + path: '/service-map', + component: () => <Home tab="service-map" />, + breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { + defaultMessage: 'Service Map' + }), + name: RouteName.SERVICE_MAP + }, + { + exact: true, + path: '/services/:serviceName/service-map', + component: () => <ServiceDetails tab="service-map" />, + breadcrumb: i18n.translate('xpack.apm.breadcrumb.serviceMapTitle', { + defaultMessage: 'Service Map' + }), + name: RouteName.SINGLE_SERVICE_MAP + }, + { + exact: true, + path: '/settings/customize-ui', + component: () => ( + <Settings> + <CustomizeUI /> + </Settings> + ), + breadcrumb: i18n.translate('xpack.apm.breadcrumb.settings.customizeUI', { + defaultMessage: 'Customize UI' + }), + name: RouteName.CUSTOMIZE_UI + } +]; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx rename to x-pack/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx b/x-pack/plugins/apm/public/components/app/Main/route_config/route_names.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx rename to x-pack/plugins/apm/public/components/app/Main/route_config/route_names.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx new file mode 100644 index 0000000000000..a1ccb04e3c42a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx @@ -0,0 +1,30 @@ +/* + * 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 { AlertType } from '../../../../../../common/alert_types'; +import { AlertAdd } from '../../../../../../../triggers_actions_ui/public'; + +type AlertAddProps = React.ComponentProps<typeof AlertAdd>; + +interface Props { + addFlyoutVisible: AlertAddProps['addFlyoutVisible']; + setAddFlyoutVisibility: AlertAddProps['setAddFlyoutVisibility']; + alertType: AlertType | null; +} + +export function AlertingFlyout(props: Props) { + const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; + + return alertType ? ( + <AlertAdd + addFlyoutVisible={addFlyoutVisible} + setAddFlyoutVisibility={setAddFlyoutVisibility} + consumer="apm" + alertTypeId={alertType} + canChangeTrigger={false} + /> + ) : null; +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx new file mode 100644 index 0000000000000..75c6c79bc804a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx @@ -0,0 +1,146 @@ +/* + * 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 { + EuiButtonEmpty, + EuiContextMenu, + EuiPopover, + EuiContextMenuPanelDescriptor +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { AlertType } from '../../../../../common/alert_types'; +import { AlertingFlyout } from './AlertingFlyout'; +import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; + +const alertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.alerts', + { + defaultMessage: 'Alerts' + } +); + +const createThresholdAlertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.createThresholdAlert', + { + defaultMessage: 'Create threshold alert' + } +); + +const CREATE_THRESHOLD_ALERT_PANEL_ID = 'create_threshold'; + +interface Props { + canReadAlerts: boolean; + canSaveAlerts: boolean; +} + +export function AlertIntegrations(props: Props) { + const { canSaveAlerts, canReadAlerts } = props; + + const plugin = useApmPluginContext(); + + const [popoverOpen, setPopoverOpen] = useState(false); + + const [alertType, setAlertType] = useState<AlertType | null>(null); + + const button = ( + <EuiButtonEmpty + iconType="arrowDown" + iconSide="right" + onClick={() => setPopoverOpen(true)} + > + {i18n.translate('xpack.apm.serviceDetails.alertsMenu.alerts', { + defaultMessage: 'Alerts' + })} + </EuiButtonEmpty> + ); + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + title: alertLabel, + items: [ + ...(canSaveAlerts + ? [ + { + name: createThresholdAlertLabel, + panel: CREATE_THRESHOLD_ALERT_PANEL_ID, + icon: 'bell' + } + ] + : []), + ...(canReadAlerts + ? [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts', + { + defaultMessage: 'View active alerts' + } + ), + href: plugin.core.http.basePath.prepend( + '/app/kibana#/management/kibana/triggersActions/alerts' + ), + icon: 'tableOfContents' + } + ] + : []) + ] + }, + { + id: CREATE_THRESHOLD_ALERT_PANEL_ID, + title: createThresholdAlertLabel, + items: [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.transactionDuration', + { + defaultMessage: 'Transaction duration' + } + ), + onClick: () => { + setAlertType(AlertType.TransactionDuration); + } + }, + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.errorRate', + { + defaultMessage: 'Error rate' + } + ), + onClick: () => { + setAlertType(AlertType.ErrorRate); + } + } + ] + } + ]; + + return ( + <> + <EuiPopover + id="integrations-menu" + button={button} + isOpen={popoverOpen} + closePopover={() => setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downRight" + > + <EuiContextMenu initialPanelId={0} panels={panels} /> + </EuiPopover> + <AlertingFlyout + alertType={alertType} + addFlyoutVisible={!!alertType} + setAddFlyoutVisibility={visible => { + if (!visible) { + setAlertType(null); + } + }} + /> + </> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx rename to x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx index 131bb7f65d4b3..7ab2f7bac8ae2 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx @@ -7,10 +7,7 @@ import { EuiTabs } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { - isJavaAgentName, - isRumAgentName -} from '../../../../../../../plugins/apm/common/agent_name'; +import { isJavaAgentName, isRumAgentName } from '../../../../common/agent_name'; import { useAgentName } from '../../../hooks/useAgentName'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; import { useUrlParams } from '../../../hooks/useUrlParams'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/TransactionSelect.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/TransactionSelect.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/TransactionSelect.tsx rename to x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/TransactionSelect.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx new file mode 100644 index 0000000000000..b7480a42ba94b --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx @@ -0,0 +1,159 @@ +/* + * 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'; +import React, { Component } from 'react'; +import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { startMLJob } from '../../../../../services/rest/ml'; +import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; +import { MLJobLink } from '../../../../shared/Links/MachineLearningLinks/MLJobLink'; +import { MachineLearningFlyoutView } from './view'; +import { ApmPluginContext } from '../../../../../context/ApmPluginContext'; + +interface Props { + isOpen: boolean; + onClose: () => void; + urlParams: IUrlParams; +} + +interface State { + isCreatingJob: boolean; +} + +export class MachineLearningFlyout extends Component<Props, State> { + static contextType = ApmPluginContext; + + public state: State = { + isCreatingJob: false + }; + + public onClickCreate = async ({ + transactionType + }: { + transactionType: string; + }) => { + this.setState({ isCreatingJob: true }); + try { + const { http } = this.context.core; + const { serviceName } = this.props.urlParams; + if (!serviceName) { + throw new Error('Service name is required to create this ML job'); + } + const res = await startMLJob({ http, serviceName, transactionType }); + const didSucceed = res.datafeeds[0].success && res.jobs[0].success; + if (!didSucceed) { + throw new Error('Creating ML job failed'); + } + this.addSuccessToast({ transactionType }); + } catch (e) { + this.addErrorToast(); + } + + this.setState({ isCreatingJob: false }); + this.props.onClose(); + }; + + public addErrorToast = () => { + const { core } = this.context; + + const { urlParams } = this.props; + const { serviceName } = urlParams; + + if (!serviceName) { + return; + } + + core.notifications.toasts.addWarning({ + title: i18n.translate( + 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationTitle', + { + defaultMessage: 'Job creation failed' + } + ), + text: toMountPoint( + <p> + {i18n.translate( + 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreationFailedNotificationText', + { + defaultMessage: + 'Your current license may not allow for creating machine learning jobs, or this job may already exist.' + } + )} + </p> + ) + }); + }; + + public addSuccessToast = ({ + transactionType + }: { + transactionType: string; + }) => { + const { core } = this.context; + const { urlParams } = this.props; + const { serviceName } = urlParams; + + if (!serviceName) { + return; + } + + core.notifications.toasts.addSuccess({ + title: i18n.translate( + 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationTitle', + { + defaultMessage: 'Job successfully created' + } + ), + text: toMountPoint( + <p> + {i18n.translate( + 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText', + { + defaultMessage: + 'The analysis is now running for {serviceName} ({transactionType}). It might take a while before results are added to the response times graph.', + values: { + serviceName, + transactionType + } + } + )}{' '} + <ApmPluginContext.Provider value={this.context}> + <MLJobLink + serviceName={serviceName} + transactionType={transactionType} + > + {i18n.translate( + 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.jobCreatedNotificationText.viewJobLinkText', + { + defaultMessage: 'View job' + } + )} + </MLJobLink> + </ApmPluginContext.Provider> + </p> + ) + }); + }; + + public render() { + const { isOpen, onClose, urlParams } = this.props; + const { serviceName } = urlParams; + const { isCreatingJob } = this.state; + + if (!isOpen || !serviceName) { + return null; + } + + return ( + <MachineLearningFlyoutView + isCreatingJob={isCreatingJob} + onClickCreate={this.onClickCreate} + onClose={onClose} + urlParams={urlParams} + /> + ); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx rename to x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx rename to x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx index 85254bee12e13..3bbd8a01d0549 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx @@ -30,12 +30,13 @@ import { padLeft, range } from 'lodash'; import moment from 'moment-timezone'; import React, { Component } from 'react'; import styled from 'styled-components'; -import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { KibanaLink } from '../../../shared/Links/KibanaLink'; import { createErrorGroupWatch, Schedule } from './createErrorGroupWatch'; import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink'; import { ApmPluginContext } from '../../../../context/ApmPluginContext'; +import { getApmIndexPatternTitle } from '../../../../services/rest/index_pattern'; type ScheduleKey = keyof Schedule; @@ -149,11 +150,7 @@ export class WatcherFlyout extends Component< this.setState({ slackUrl: event.target.value }); }; - public createWatch = ({ - indexPatternTitle - }: { - indexPatternTitle: string; - }) => () => { + public createWatch = () => { const { serviceName } = this.props.urlParams; const { core } = this.context; @@ -190,19 +187,21 @@ export class WatcherFlyout extends Component< unit: 'h' }; - return createErrorGroupWatch({ - http: core.http, - emails, - schedule, - serviceName, - slackUrl, - threshold: this.state.threshold, - timeRange, - apmIndexPatternTitle: indexPatternTitle - }) - .then((id: string) => { - this.props.onClose(); - this.addSuccessToast(id); + return getApmIndexPatternTitle() + .then(indexPatternTitle => { + return createErrorGroupWatch({ + http: core.http, + emails, + schedule, + serviceName, + slackUrl, + threshold: this.state.threshold, + timeRange, + apmIndexPatternTitle: indexPatternTitle + }).then((id: string) => { + this.props.onClose(); + this.addSuccessToast(id); + }); }) .catch(e => { // eslint-disable-next-line @@ -613,26 +612,20 @@ export class WatcherFlyout extends Component< <EuiFlyoutFooter> <EuiFlexGroup justifyContent="flexEnd"> <EuiFlexItem grow={false}> - <ApmPluginContext.Consumer> - {({ config }) => { - return ( - <EuiButton - onClick={this.createWatch(config)} - fill - disabled={ - !this.state.actions.email && !this.state.actions.slack - } - > - {i18n.translate( - 'xpack.apm.serviceDetails.enableErrorReportsPanel.createWatchButtonLabel', - { - defaultMessage: 'Create watch' - } - )} - </EuiButton> - ); - }} - </ApmPluginContext.Consumer> + <EuiButton + onClick={() => this.createWatch()} + fill + disabled={ + !this.state.actions.email && !this.state.actions.slack + } + > + {i18n.translate( + 'xpack.apm.serviceDetails.enableErrorReportsPanel.createWatchButtonLabel', + { + defaultMessage: 'Create watch' + } + )} + </EuiButton> </EuiFlexItem> </EuiFlexGroup> </EuiFlyoutFooter> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/__snapshots__/createErrorGroupWatch.test.ts.snap b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/__snapshots__/createErrorGroupWatch.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/__snapshots__/createErrorGroupWatch.test.ts.snap rename to x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/__snapshots__/createErrorGroupWatch.test.ts.snap diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/createErrorGroupWatch.test.ts b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/createErrorGroupWatch.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/createErrorGroupWatch.test.ts rename to x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/createErrorGroupWatch.test.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/esResponse.ts b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/esResponse.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/esResponse.ts rename to x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/esResponse.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts similarity index 98% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts rename to x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts index 690db9fcdd8d6..d45453e24f1c9 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts @@ -17,7 +17,7 @@ import { ERROR_LOG_MESSAGE, PROCESSOR_EVENT, SERVICE_NAME -} from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +} from '../../../../../common/elasticsearch_fieldnames'; import { createWatch } from '../../../../services/rest/watcher'; function getSlackPathUrl(slackUrl?: string) { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx rename to x-pack/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx rename to x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Controls.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Controls.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx similarity index 89% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index de775dbc8162a..340c299f52c0b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -304,3 +304,44 @@ storiesOf('app/ServiceMap/Cytoscape', module) } ) .addParameters({ options: { showPanel: false } }); + +storiesOf('app/ServiceMap/Cytoscape', module).add( + 'node severity', + () => { + const elements = [ + { data: { id: 'undefined', 'service.name': 'severity: undefined' } }, + { + data: { + id: 'warning', + 'service.name': 'severity: warning', + severity: 'warning' + } + }, + { + data: { + id: 'minor', + 'service.name': 'severity: minor', + severity: 'minor' + } + }, + { + data: { + id: 'major', + 'service.name': 'severity: major', + severity: 'major' + } + }, + { + data: { + id: 'critical', + 'service.name': 'severity: critical', + severity: 'critical' + } + } + ]; + return <Cytoscape elements={elements} height={300} width={1340} />; + }, + { + info: { propTables: false, source: false } + } +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx similarity index 98% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 53c86f92ee557..ad77434bca9f4 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -19,7 +19,7 @@ import { cytoscapeOptions, nodeHeight } from './cytoscapeOptions'; -import { useUiTracker } from '../../../../../../../plugins/observability/public'; +import { useUiTracker } from '../../../../../observability/public'; export const CytoscapeContext = createContext<cytoscape.Core | undefined>( undefined diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/EmptyBanner.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index 491ebdc5aad15..bc3434f277d1c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -12,7 +12,7 @@ import { } from '@elastic/eui'; import cytoscape from 'cytoscape'; import React from 'react'; -import { SERVICE_FRAMEWORK_NAME } from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +import { SERVICE_FRAMEWORK_NAME } from '../../../../../common/elasticsearch_fieldnames'; import { Buttons } from './Buttons'; import { Info } from './Info'; import { ServiceMetricFetcher } from './ServiceMetricFetcher'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx index e1df3b474e9de..541f4f6a1e775 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx @@ -12,7 +12,7 @@ import styled from 'styled-components'; import { SPAN_SUBTYPE, SPAN_TYPE -} from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +} from '../../../../../common/elasticsearch_fieldnames'; const ItemRow = styled.div` line-height: 2; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx index 697aa6a1b652b..5e6412333a2e1 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { ServiceNodeMetrics } from '../../../../../../../../plugins/apm/common/service_map'; +import { ServiceNodeMetrics } from '../../../../../common/service_map'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { ServiceMetricList } from './ServiceMetricList'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx index 056af68cc8173..3cee986261a68 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx @@ -15,7 +15,7 @@ import { i18n } from '@kbn/i18n'; import { isNumber } from 'lodash'; import React from 'react'; import styled from 'styled-components'; -import { ServiceNodeMetrics } from '../../../../../../../../plugins/apm/common/service_map'; +import { ServiceNodeMetrics } from '../../../../../common/service_map'; import { asDuration, asPercent, tpmUnit } from '../../../../utils/formatters'; function LoadingSpinner() { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx new file mode 100644 index 0000000000000..1c9d5092bfcf5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx @@ -0,0 +1,119 @@ +/* + * 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 { EuiPopover } from '@elastic/eui'; +import cytoscape from 'cytoscape'; +import React, { + CSSProperties, + useCallback, + useContext, + useEffect, + useRef, + useState +} from 'react'; +import { SERVICE_NAME } from '../../../../../common/elasticsearch_fieldnames'; +import { CytoscapeContext } from '../Cytoscape'; +import { Contents } from './Contents'; +import { animationOptions } from '../cytoscapeOptions'; + +interface PopoverProps { + focusedServiceName?: string; +} + +export function Popover({ focusedServiceName }: PopoverProps) { + const cy = useContext(CytoscapeContext); + const [selectedNode, setSelectedNode] = useState< + cytoscape.NodeSingular | undefined + >(undefined); + const deselect = useCallback(() => { + if (cy) { + cy.elements().unselect(); + } + setSelectedNode(undefined); + }, [cy, setSelectedNode]); + const renderedHeight = selectedNode?.renderedHeight() ?? 0; + const renderedWidth = selectedNode?.renderedWidth() ?? 0; + const { x, y } = selectedNode?.renderedPosition() ?? { x: -10000, y: -10000 }; + const isOpen = !!selectedNode; + const isService = selectedNode?.data(SERVICE_NAME) !== undefined; + const triggerStyle: CSSProperties = { + background: 'transparent', + height: renderedHeight, + position: 'absolute', + width: renderedWidth + }; + const trigger = <div style={triggerStyle} />; + const zoom = cy?.zoom() ?? 1; + const height = selectedNode?.height() ?? 0; + const translateY = y - ((zoom + 1) * height) / 4; + const popoverStyle: CSSProperties = { + position: 'absolute', + transform: `translate(${x}px, ${translateY}px)` + }; + const selectedNodeData = selectedNode?.data() ?? {}; + const selectedNodeServiceName = selectedNodeData.id; + const label = selectedNodeData.label || selectedNodeServiceName; + const popoverRef = useRef<EuiPopover>(null); + + // Set up Cytoscape event handlers + useEffect(() => { + const selectHandler: cytoscape.EventHandler = event => { + setSelectedNode(event.target); + }; + + if (cy) { + cy.on('select', 'node', selectHandler); + cy.on('unselect', 'node', deselect); + cy.on('data viewport', deselect); + } + + return () => { + if (cy) { + cy.removeListener('select', 'node', selectHandler); + cy.removeListener('unselect', 'node', deselect); + cy.removeListener('data viewport', undefined, deselect); + } + }; + }, [cy, deselect]); + + // Handle positioning of popover. This makes it so the popover positions + // itself correctly and the arrows are always pointing to where they should. + useEffect(() => { + if (popoverRef.current) { + popoverRef.current.positionPopoverFluid(); + } + }, [popoverRef, x, y]); + + const centerSelectedNode = useCallback(() => { + if (cy) { + cy.animate({ + ...animationOptions, + center: { eles: cy.getElementById(selectedNodeServiceName) } + }); + } + }, [cy, selectedNodeServiceName]); + + const isAlreadyFocused = focusedServiceName === selectedNodeServiceName; + + return ( + <EuiPopover + anchorPosition={'upCenter'} + button={trigger} + closePopover={() => {}} + isOpen={isOpen} + ref={popoverRef} + style={popoverStyle} + > + <Contents + isService={isService} + label={label} + onFocusClick={isAlreadyFocused ? centerSelectedNode : deselect} + selectedNodeData={selectedNodeData} + selectedNodeServiceName={selectedNodeServiceName} + /> + </EuiPopover> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscape-layout-test-response.json b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape-layout-test-response.json similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscape-layout-test-response.json rename to x-pack/plugins/apm/public/components/app/ServiceMap/cytoscape-layout-test-response.json diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts similarity index 82% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts rename to x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index e9942a327b69e..3bb4319d0722d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -9,9 +9,53 @@ import { CSSProperties } from 'react'; import { SERVICE_NAME, SPAN_DESTINATION_SERVICE_RESOURCE -} from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +} from '../../../../common/elasticsearch_fieldnames'; +import { severity } from '../../../../common/ml_job_constants'; import { defaultIcon, iconForNode } from './icons'; +const getBorderColor = (el: cytoscape.NodeSingular) => { + const nodeSeverity = el.data('severity'); + + switch (nodeSeverity) { + case severity.warning: + return theme.euiColorVis0; + case severity.minor || severity.major: + return theme.euiColorVis5; + case severity.critical: + return theme.euiColorVis9; + default: + if (el.hasClass('primary') || el.selected()) { + return theme.euiColorPrimary; + } else { + return theme.euiColorMediumShade; + } + } +}; + +const getBorderStyle: cytoscape.Css.MapperFunction< + cytoscape.NodeSingular, + cytoscape.Css.LineStyle +> = (el: cytoscape.NodeSingular) => { + const nodeSeverity = el.data('severity'); + if (nodeSeverity === severity.critical) { + return 'double'; + } else { + return 'solid'; + } +}; + +const getBorderWidth = (el: cytoscape.NodeSingular) => { + const nodeSeverity = el.data('severity'); + + if (nodeSeverity === severity.minor || nodeSeverity === severity.major) { + return 4; + } else if (nodeSeverity === severity.critical) { + return 12; + } else { + return 2; + } +}; + // IE 11 does not properly load some SVGs or draw certain shapes. This causes // a runtime error and the map fails work at all. We would prefer to do some // kind of feature detection rather than browser detection, but some of these @@ -55,11 +99,9 @@ const style: cytoscape.Stylesheet[] = [ isService(el) ? '60%' : '40%', 'background-width': (el: cytoscape.NodeSingular) => isService(el) ? '60%' : '40%', - 'border-color': (el: cytoscape.NodeSingular) => - el.hasClass('primary') || el.selected() - ? theme.euiColorPrimary - : theme.euiColorMediumShade, - 'border-width': 2, + 'border-color': getBorderColor, + 'border-style': getBorderStyle, + 'border-width': getBorderWidth, color: (el: cytoscape.NodeSingular) => el.hasClass('primary') || el.selected() ? theme.euiColorPrimaryText @@ -149,7 +191,7 @@ const style: cytoscape.Stylesheet[] = [ { selector: 'node.hover', style: { - 'border-width': 2 + 'border-width': getBorderWidth } }, { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts index 321b39dabbbd5..9fe5cbd23b07c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -5,12 +5,12 @@ */ import cytoscape from 'cytoscape'; -import { isRumAgentName } from '../../../../../../../plugins/apm/common/agent_name'; +import { isRumAgentName } from '../../../../common/agent_name'; import { AGENT_NAME, SPAN_SUBTYPE, SPAN_TYPE -} from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +} from '../../../../common/elasticsearch_fieldnames'; import awsIcon from './icons/aws.svg'; import cassandraIcon from './icons/cassandra.svg'; import darkIcon from './icons/dark.svg'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/aws.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/aws.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/aws.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/aws.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/cassandra.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/cassandra.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/cassandra.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/cassandra.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dark.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dark.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dark.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/dark.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/database.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/database.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/database.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/database.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/default.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/default.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/documents.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/documents.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/documents.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/documents.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/globe.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/globe.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/globe.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/globe.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/go.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/go.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/graphql.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/graphql.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/graphql.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/graphql.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/grpc.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/grpc.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/grpc.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/grpc.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/handlebars.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/handlebars.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/handlebars.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/handlebars.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/java.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/java.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/kafka.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/kafka.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/kafka.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/kafka.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/mongodb.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/mongodb.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/mongodb.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/mongodb.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/mysql.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/mysql.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/mysql.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/mysql.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/php.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/php.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/postgresql.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/postgresql.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/postgresql.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/postgresql.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/python.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/python.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/redis.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/redis.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/redis.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/redis.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/websocket.svg b/x-pack/plugins/apm/public/components/app/ServiceMap/icons/websocket.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/websocket.svg rename to x-pack/plugins/apm/public/components/app/ServiceMap/icons/websocket.svg diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx new file mode 100644 index 0000000000000..c7e25269511bf --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 { render } from '@testing-library/react'; +import React, { FunctionComponent } from 'react'; +import { License } from '../../../../../licensing/common/license'; +import { LicenseContext } from '../../../context/LicenseContext'; +import { ServiceMap } from './'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; + +const expiredLicense = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'platinum', + status: 'expired', + type: 'platinum', + uid: '1' + } +}); + +const Wrapper: FunctionComponent = ({ children }) => { + return ( + <LicenseContext.Provider value={expiredLicense}> + <MockApmPluginContextWrapper>{children}</MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); +}; + +describe('ServiceMap', () => { + describe('with an inactive license', () => { + it('renders the license banner', async () => { + expect( + ( + await render(<ServiceMap />, { + wrapper: Wrapper + }).findAllByText(/Platinum/) + ).length + ).toBeGreaterThan(0); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx new file mode 100644 index 0000000000000..b57f0b047c613 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import { + invalidLicenseMessage, + isValidPlatinumLicense +} from '../../../../common/service_map'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { useLicense } from '../../../hooks/useLicense'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; +import { LicensePrompt } from '../../shared/LicensePrompt'; +import { Controls } from './Controls'; +import { Cytoscape } from './Cytoscape'; +import { cytoscapeDivStyle } from './cytoscapeOptions'; +import { EmptyBanner } from './EmptyBanner'; +import { Popover } from './Popover'; +import { useRefDimensions } from './useRefDimensions'; +import { BetaBadge } from './BetaBadge'; +import { useTrackPageview } from '../../../../../observability/public'; + +interface ServiceMapProps { + serviceName?: string; +} + +export function ServiceMap({ serviceName }: ServiceMapProps) { + const license = useLicense(); + const { urlParams } = useUrlParams(); + + const { data = { elements: [] } } = useFetcher(() => { + // When we don't have a license or a valid license, don't make the request. + if (!license || !isValidPlatinumLicense(license)) { + return; + } + + const { start, end, environment } = urlParams; + if (start && end) { + return callApmApi({ + isCachable: false, + pathname: '/api/apm/service-map', + params: { + query: { + start, + end, + environment, + serviceName + } + } + }); + } + }, [license, serviceName, urlParams]); + + const { ref, height, width } = useRefDimensions(); + + useTrackPageview({ app: 'apm', path: 'service_map' }); + useTrackPageview({ app: 'apm', path: 'service_map', delay: 15000 }); + + if (!license) { + return null; + } + + return isValidPlatinumLicense(license) ? ( + <div + style={{ height: height - parseInt(theme.gutterTypes.gutterLarge, 10) }} + ref={ref} + > + <Cytoscape + elements={data?.elements ?? []} + height={height} + serviceName={serviceName} + style={cytoscapeDivStyle} + width={width} + > + <Controls /> + <BetaBadge /> + {serviceName && <EmptyBanner />} + <Popover focusedServiceName={serviceName} /> + </Cytoscape> + </div> + ) : ( + <EuiFlexGroup + alignItems="center" + justifyContent="spaceAround" + // Set the height to give it some top margin + style={{ height: '60vh' }} + > + <EuiFlexItem + grow={false} + style={{ width: 600, textAlign: 'center' as const }} + > + <LicensePrompt text={invalidLicenseMessage} showBetaBadge /> + </EuiFlexItem> + </EuiFlexGroup> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/useRefDimensions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/useRefDimensions.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceMap/useRefDimensions.ts rename to x-pack/plugins/apm/public/components/app/ServiceMap/useRefDimensions.ts diff --git a/x-pack/plugins/apm/public/components/app/ServiceMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMetrics/index.tsx new file mode 100644 index 0000000000000..0fb8c00a2b162 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMetrics/index.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 { + EuiFlexGrid, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiFlexGroup +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; +import { MetricsChart } from '../../shared/charts/MetricsChart'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; +import { PROJECTION } from '../../../../common/projections/typings'; +import { LocalUIFilters } from '../../shared/LocalUIFilters'; + +interface ServiceMetricsProps { + agentName: string; +} + +export function ServiceMetrics({ agentName }: ServiceMetricsProps) { + const { urlParams } = useUrlParams(); + const { serviceName, serviceNodeName } = urlParams; + const { data } = useServiceMetricCharts(urlParams, agentName); + const { start, end } = urlParams; + + const localFiltersConfig: React.ComponentProps<typeof LocalUIFilters> = useMemo( + () => ({ + filterNames: ['host', 'containerId', 'podName', 'serviceVersion'], + params: { + serviceName, + serviceNodeName + }, + projection: PROJECTION.METRICS, + showCount: false + }), + [serviceName, serviceNodeName] + ); + + return ( + <> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem grow={1}> + <LocalUIFilters {...localFiltersConfig} /> + </EuiFlexItem> + <EuiFlexItem grow={7}> + <ChartsSyncContextProvider> + <EuiFlexGrid columns={2} gutterSize="s"> + {data.charts.map(chart => ( + <EuiFlexItem key={chart.key}> + <EuiPanel> + <MetricsChart start={start} end={end} chart={chart} /> + </EuiPanel> + </EuiFlexItem> + ))} + </EuiFlexGrid> + <EuiSpacer size="xxl" /> + </ChartsSyncContextProvider> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx rename to x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx new file mode 100644 index 0000000000000..3929c153ae419 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceNodeMetrics/index.tsx @@ -0,0 +1,186 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiHorizontalRule, + EuiFlexGrid, + EuiPanel, + EuiSpacer, + EuiStat, + EuiToolTip, + EuiCallOut +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; +import { ApmHeader } from '../../shared/ApmHeader'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useAgentName } from '../../../hooks/useAgentName'; +import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts'; +import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; +import { MetricsChart } from '../../shared/charts/MetricsChart'; +import { useFetcher, FETCH_STATUS } from '../../../hooks/useFetcher'; +import { truncate, px, unit } from '../../../style/variables'; +import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; + +const INITIAL_DATA = { + host: '', + containerId: '' +}; + +const Truncate = styled.span` + display: block; + ${truncate(px(unit * 12))} +`; + +export function ServiceNodeMetrics() { + const { urlParams, uiFilters } = useUrlParams(); + const { serviceName, serviceNodeName } = urlParams; + + const { agentName } = useAgentName(); + const { data } = useServiceMetricCharts(urlParams, agentName); + const { start, end } = urlParams; + + const { data: { host, containerId } = INITIAL_DATA, status } = useFetcher( + callApmApi => { + if (serviceName && serviceNodeName && start && end) { + return callApmApi({ + pathname: + '/api/apm/services/{serviceName}/node/{serviceNodeName}/metadata', + params: { + path: { serviceName, serviceNodeName }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters) + } + } + }); + } + }, + [serviceName, serviceNodeName, start, end, uiFilters] + ); + + const isLoading = status === FETCH_STATUS.LOADING; + + const isAggregatedData = serviceNodeName === SERVICE_NODE_NAME_MISSING; + + return ( + <div> + <ApmHeader> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <EuiTitle size="l"> + <h1>{serviceName}</h1> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + </ApmHeader> + <EuiHorizontalRule margin="m" /> + {isAggregatedData ? ( + <EuiCallOut + title={i18n.translate( + 'xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningTitle', + { + defaultMessage: 'Could not identify JVMs' + } + )} + iconType="help" + color="warning" + > + <FormattedMessage + id="xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningText" + defaultMessage="We could not identify which JVMs these metrics belong to. This is likely caused by running a version of APM Server that is older than 7.5. Upgrading to APM Server 7.5 or higher should resolve this issue. For more information on upgrading, see the {link}. As an alternative, you can use the Kibana Query bar to filter by hostname, container ID or other fields." + values={{ + link: ( + <ElasticDocsLink + target="_blank" + section="/apm/server" + path="/upgrading.html" + > + {i18n.translate( + 'xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningDocumentationLink', + { defaultMessage: 'documentation of APM Server' } + )} + </ElasticDocsLink> + ) + }} + /> + </EuiCallOut> + ) : ( + <EuiFlexGroup gutterSize="xl"> + <EuiFlexItem grow={false}> + <EuiStat + titleSize="s" + description={i18n.translate( + 'xpack.apm.serviceNodeMetrics.serviceName', + { + defaultMessage: 'Service name' + } + )} + title={ + <EuiToolTip content={serviceName}> + <Truncate>{serviceName}</Truncate> + </EuiToolTip> + } + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiStat + titleSize="s" + isLoading={isLoading} + description={i18n.translate('xpack.apm.serviceNodeMetrics.host', { + defaultMessage: 'Host' + })} + title={ + <EuiToolTip content={host}> + <Truncate>{host}</Truncate> + </EuiToolTip> + } + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiStat + titleSize="s" + isLoading={isLoading} + description={i18n.translate( + 'xpack.apm.serviceNodeMetrics.containerId', + { + defaultMessage: 'Container ID' + } + )} + title={ + <EuiToolTip content={containerId}> + <Truncate>{containerId}</Truncate> + </EuiToolTip> + } + /> + </EuiFlexItem> + </EuiFlexGroup> + )} + <EuiHorizontalRule margin="m" /> + {agentName && serviceNodeName && ( + <ChartsSyncContextProvider> + <EuiFlexGrid columns={2} gutterSize="s"> + {data.charts.map(chart => ( + <EuiFlexItem key={chart.key}> + <EuiPanel> + <MetricsChart start={start} end={end} chart={chart} /> + </EuiPanel> + </EuiFlexItem> + ))} + </EuiFlexGrid> + <EuiSpacer size="xxl" /> + </ChartsSyncContextProvider> + )} + </div> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx new file mode 100644 index 0000000000000..4e57cb47691be --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceNodeOverview/index.tsx @@ -0,0 +1,187 @@ +/* + * 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, { useMemo } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiToolTip, + EuiSpacer +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../common/i18n'; +import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; +import { PROJECTION } from '../../../../common/projections/typings'; +import { LocalUIFilters } from '../../shared/LocalUIFilters'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { ManagedTable, ITableColumn } from '../../shared/ManagedTable'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { + asDynamicBytes, + asInteger, + asPercent +} from '../../../utils/formatters'; +import { ServiceNodeMetricOverviewLink } from '../../shared/Links/apm/ServiceNodeMetricOverviewLink'; +import { truncate, px, unit } from '../../../style/variables'; + +const INITIAL_PAGE_SIZE = 25; +const INITIAL_SORT_FIELD = 'cpu'; +const INITIAL_SORT_DIRECTION = 'desc'; + +const ServiceNodeName = styled.div` + ${truncate(px(8 * unit))} +`; + +const ServiceNodeOverview = () => { + const { uiFilters, urlParams } = useUrlParams(); + const { serviceName, start, end } = urlParams; + + const localFiltersConfig: React.ComponentProps<typeof LocalUIFilters> = useMemo( + () => ({ + filterNames: ['host', 'containerId', 'podName'], + params: { + serviceName + }, + projection: PROJECTION.SERVICE_NODES + }), + [serviceName] + ); + + const { data: items = [] } = useFetcher( + callApmApi => { + if (!serviceName || !start || !end) { + return undefined; + } + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/serviceNodes', + params: { + path: { + serviceName + }, + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters) + } + } + }); + }, + [serviceName, start, end, uiFilters] + ); + + if (!serviceName) { + return null; + } + + const columns: Array<ITableColumn<typeof items[0]>> = [ + { + name: ( + <EuiToolTip + content={i18n.translate('xpack.apm.jvmsTable.nameExplanation', { + defaultMessage: `By default, the JVM name is the container ID (where applicable) or the hostname, but it can be manually configured through the agent's 'service_node_name' configuration.` + })} + > + <> + {i18n.translate('xpack.apm.jvmsTable.nameColumnLabel', { + defaultMessage: 'Name' + })} + </> + </EuiToolTip> + ), + field: 'name', + sortable: true, + render: (name: string) => { + const { displayedName, tooltip } = + name === SERVICE_NODE_NAME_MISSING + ? { + displayedName: UNIDENTIFIED_SERVICE_NODES_LABEL, + tooltip: i18n.translate( + 'xpack.apm.jvmsTable.explainServiceNodeNameMissing', + { + defaultMessage: + 'We could not identify which JVMs these metrics belong to. This is likely caused by running a version of APM Server that is older than 7.5. Upgrading to APM Server 7.5 or higher should resolve this issue.' + } + ) + } + : { displayedName: name, tooltip: name }; + + return ( + <EuiToolTip content={tooltip}> + <ServiceNodeMetricOverviewLink + serviceName={serviceName} + serviceNodeName={name} + > + <ServiceNodeName>{displayedName}</ServiceNodeName> + </ServiceNodeMetricOverviewLink> + </EuiToolTip> + ); + } + }, + { + name: i18n.translate('xpack.apm.jvmsTable.cpuColumnLabel', { + defaultMessage: 'CPU avg' + }), + field: 'cpu', + sortable: true, + render: (value: number | null) => asPercent(value || 0, 1) + }, + { + name: i18n.translate('xpack.apm.jvmsTable.heapMemoryColumnLabel', { + defaultMessage: 'Heap memory avg' + }), + field: 'heapMemory', + sortable: true, + render: asDynamicBytes + }, + { + name: i18n.translate('xpack.apm.jvmsTable.nonHeapMemoryColumnLabel', { + defaultMessage: 'Non-heap memory avg' + }), + field: 'nonHeapMemory', + sortable: true, + render: asDynamicBytes + }, + { + name: i18n.translate('xpack.apm.jvmsTable.threadCountColumnLabel', { + defaultMessage: 'Thread count max' + }), + field: 'threadCount', + sortable: true, + render: asInteger + } + ]; + + return ( + <> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem grow={1}> + <LocalUIFilters {...localFiltersConfig} /> + </EuiFlexItem> + <EuiFlexItem grow={7}> + <EuiPanel> + <ManagedTable + noItemsMessage={i18n.translate( + 'xpack.apm.jvmsTable.noJvmsLabel', + { + defaultMessage: 'No JVMs were found' + } + )} + items={items} + columns={columns} + initialPageSize={INITIAL_PAGE_SIZE} + initialSortField={INITIAL_SORT_FIELD} + initialSortDirection={INITIAL_SORT_DIRECTION} + /> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); +}; + +export { ServiceNodeOverview }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/NoServicesMessage.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/NoServicesMessage.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/NoServicesMessage.tsx rename to x-pack/plugins/apm/public/components/app/ServiceOverview/NoServicesMessage.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js rename to x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap rename to x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json rename to x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/props.json diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx new file mode 100644 index 0000000000000..7e2d03ad35899 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx @@ -0,0 +1,135 @@ +/* + * 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 { EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_services'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { fontSizes, truncate } from '../../../../style/variables'; +import { asDecimal, convertTo } from '../../../../utils/formatters'; +import { ManagedTable } from '../../../shared/ManagedTable'; +import { EnvironmentBadge } from '../../../shared/EnvironmentBadge'; +import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink'; + +interface Props { + items: ServiceListAPIResponse['items']; + noItemsMessage?: React.ReactNode; +} + +function formatNumber(value: number) { + if (value === 0) { + return '0'; + } else if (value <= 0.1) { + return '< 0.1'; + } else { + return asDecimal(value); + } +} + +function formatString(value?: string | null) { + return value || NOT_AVAILABLE_LABEL; +} + +const AppLink = styled(TransactionOverviewLink)` + font-size: ${fontSizes.large}; + ${truncate('100%')}; +`; + +export const SERVICE_COLUMNS = [ + { + field: 'serviceName', + name: i18n.translate('xpack.apm.servicesTable.nameColumnLabel', { + defaultMessage: 'Name' + }), + width: '40%', + sortable: true, + render: (serviceName: string) => ( + <EuiToolTip content={formatString(serviceName)} id="service-name-tooltip"> + <AppLink serviceName={serviceName}>{formatString(serviceName)}</AppLink> + </EuiToolTip> + ) + }, + { + field: 'environments', + name: i18n.translate('xpack.apm.servicesTable.environmentColumnLabel', { + defaultMessage: 'Environment' + }), + width: '20%', + sortable: true, + render: (environments: string[]) => ( + <EnvironmentBadge environments={environments} /> + ) + }, + { + field: 'agentName', + name: i18n.translate('xpack.apm.servicesTable.agentColumnLabel', { + defaultMessage: 'Agent' + }), + sortable: true, + render: (agentName: string) => formatString(agentName) + }, + { + field: 'avgResponseTime', + name: i18n.translate('xpack.apm.servicesTable.avgResponseTimeColumnLabel', { + defaultMessage: 'Avg. response time' + }), + sortable: true, + dataType: 'number', + render: (time: number) => + convertTo({ + unit: 'milliseconds', + microseconds: time + }).formatted + }, + { + field: 'transactionsPerMinute', + name: i18n.translate( + 'xpack.apm.servicesTable.transactionsPerMinuteColumnLabel', + { + defaultMessage: 'Trans. per minute' + } + ), + sortable: true, + dataType: 'number', + render: (value: number) => + `${formatNumber(value)} ${i18n.translate( + 'xpack.apm.servicesTable.transactionsPerMinuteUnitLabel', + { + defaultMessage: 'tpm' + } + )}` + }, + { + field: 'errorsPerMinute', + name: i18n.translate('xpack.apm.servicesTable.errorsPerMinuteColumnLabel', { + defaultMessage: 'Errors per minute' + }), + sortable: true, + dataType: 'number', + render: (value: number) => + `${formatNumber(value)} ${i18n.translate( + 'xpack.apm.servicesTable.errorsPerMinuteUnitLabel', + { + defaultMessage: 'err.' + } + )}` + } +]; + +export function ServiceList({ items, noItemsMessage }: Props) { + return ( + <ManagedTable + columns={SERVICE_COLUMNS} + items={items} + noItemsMessage={noItemsMessage} + initialSortField="serviceName" + initialPageSize={50} + /> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/NoServicesMessage.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/NoServicesMessage.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/NoServicesMessage.test.tsx rename to x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/NoServicesMessage.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx rename to x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap rename to x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap rename to x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx new file mode 100644 index 0000000000000..99b169e3ec361 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/index.tsx @@ -0,0 +1,119 @@ +/* + * 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 { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useMemo } from 'react'; +import url from 'url'; +import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { NoServicesMessage } from './NoServicesMessage'; +import { ServiceList } from './ServiceList'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useTrackPageview } from '../../../../../observability/public'; +import { PROJECTION } from '../../../../common/projections/typings'; +import { LocalUIFilters } from '../../shared/LocalUIFilters'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; + +const initalData = { + items: [], + hasHistoricalData: true, + hasLegacyData: false +}; + +let hasDisplayedToast = false; + +export function ServiceOverview() { + const { core } = useApmPluginContext(); + const { + urlParams: { start, end }, + uiFilters + } = useUrlParams(); + const { data = initalData, status } = useFetcher( + callApmApi => { + if (start && end) { + return callApmApi({ + pathname: '/api/apm/services', + params: { + query: { start, end, uiFilters: JSON.stringify(uiFilters) } + } + }); + } + }, + [start, end, uiFilters] + ); + + useEffect(() => { + if (data.hasLegacyData && !hasDisplayedToast) { + hasDisplayedToast = true; + + core.notifications.toasts.addWarning({ + title: i18n.translate('xpack.apm.serviceOverview.toastTitle', { + defaultMessage: + 'Legacy data was detected within the selected time range' + }), + text: toMountPoint( + <p> + {i18n.translate('xpack.apm.serviceOverview.toastText', { + defaultMessage: + "You're running Elastic Stack 7.0+ and we've detected incompatible data from a previous 6.x version. If you want to view this data in APM, you should migrate it. See more in " + })} + + <EuiLink + href={url.format({ + pathname: core.http.basePath.prepend('/app/kibana'), + hash: '/management/elasticsearch/upgrade_assistant' + })} + > + {i18n.translate( + 'xpack.apm.serviceOverview.upgradeAssistantLink', + { + defaultMessage: 'the upgrade assistant' + } + )} + </EuiLink> + </p> + ) + }); + } + }, [data.hasLegacyData, core.http.basePath, core.notifications.toasts]); + + useTrackPageview({ app: 'apm', path: 'services_overview' }); + useTrackPageview({ app: 'apm', path: 'services_overview', delay: 15000 }); + + const localFiltersConfig: React.ComponentProps<typeof LocalUIFilters> = useMemo( + () => ({ + filterNames: ['host', 'agentName'], + projection: PROJECTION.SERVICES + }), + [] + ); + + return ( + <> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem grow={1}> + <LocalUIFilters {...localFiltersConfig} /> + </EuiFlexItem> + <EuiFlexItem grow={7}> + <EuiPanel> + <ServiceList + items={data.items} + noItemsMessage={ + <NoServicesMessage + historicalDataFound={data.hasHistoricalData} + status={status} + /> + } + /> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx rename to x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx similarity index 96% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx rename to x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx index 43002c79aa2b4..f4b942c7f46eb 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx @@ -16,11 +16,11 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { isString } from 'lodash'; import { EuiButtonEmpty } from '@elastic/eui'; -import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { AgentConfigurationIntake } from '../../../../../../../common/agent_configuration/configuration_types'; import { omitAllOption, getOptionLabel -} from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +} from '../../../../../../../common/agent_configuration/all_option'; import { useFetcher, FETCH_STATUS } from '../../../../../../hooks/useFetcher'; import { FormRowSelect } from './FormRowSelect'; import { APMLink } from '../../../../../shared/Links/apm/APMLink'; diff --git a/x-pack/legacy/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 similarity index 84% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx rename to x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx index baab600145b81..6711fecc2376c 100644 --- a/x-pack/legacy/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 @@ -17,12 +17,12 @@ import { EuiIconTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { SettingDefinition } from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions/types'; -import { isValid } from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions'; +import { SettingDefinition } from '../../../../../../../common/agent_configuration/setting_definitions/types'; +import { validateSetting } from '../../../../../../../common/agent_configuration/setting_definitions'; import { amountAndUnitToString, amountAndUnitToObject -} from '../../../../../../../../../../plugins/apm/common/agent_configuration/amount_and_unit'; +} from '../../../../../../../common/agent_configuration/amount_and_unit'; import { SelectWithPlaceholder } from '../../../../../shared/SelectWithPlaceholder'; function FormRow({ @@ -92,12 +92,14 @@ function FormRow({ <EuiFlexItem grow={false}> <EuiFieldNumber placeholder={setting.placeholder} - value={(amount as unknown) as number} - min={'min' in setting ? setting.min : 1} + value={amount} onChange={e => 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 ( <EuiDescribedFormGroup @@ -170,11 +173,7 @@ export function SettingFormRow({ </> } > - <EuiFormRow - label={setting.key} - error={setting.validationError} - isInvalid={isInvalid} - > + <EuiFormRow label={setting.key} error={message} isInvalid={isInvalid}> <FormRow onChange={onChange} setting={setting} value={value} /> </EuiFormRow> </EuiDescribedFormGroup> diff --git a/x-pack/legacy/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 similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx rename to x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx index 6d76b69600333..bb3c2b3249363 100644 --- a/x-pack/legacy/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 @@ -23,19 +23,19 @@ import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty } from '@elastic/eui'; import { EuiCallOut } from '@elastic/eui'; import { FETCH_STATUS } from '../../../../../../hooks/useFetcher'; -import { AgentName } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/fields/agent'; +import { AgentName } from '../../../../../../../typings/es_schemas/ui/fields/agent'; import { history } from '../../../../../../utils/history'; -import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { AgentConfigurationIntake } from '../../../../../../../common/agent_configuration/configuration_types'; import { filterByAgent, settingDefinitions, - isValid -} from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions'; + validateSetting +} from '../../../../../../../common/agent_configuration/setting_definitions'; import { saveConfig } from './saveConfig'; import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; -import { useUiTracker } from '../../../../../../../../../../plugins/observability/public'; +import { useUiTracker } from '../../../../../../../../observability/public'; import { SettingFormRow } from './SettingFormRow'; -import { getOptionLabel } from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { getOptionLabel } from '../../../../../../../common/agent_configuration/all_option'; function removeEmpty<T>(obj: T): T { return Object.fromEntries( @@ -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/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts similarity index 90% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts rename to x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts index 7e3bcd68699be..5f7354bf6f713 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts @@ -6,11 +6,11 @@ import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'kibana/public'; -import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { AgentConfigurationIntake } from '../../../../../../../common/agent_configuration/configuration_types'; import { getOptionLabel, omitAllOption -} from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +} from '../../../../../../../common/agent_configuration/all_option'; import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; export async function saveConfig({ diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx rename to x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx index 531e557b6ef86..089bc58f50a88 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -13,7 +13,7 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; import { HttpSetup } from 'kibana/public'; -import { AgentConfiguration } from '../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { AgentConfiguration } from '../../../../../../common/agent_configuration/configuration_types'; import { FETCH_STATUS } from '../../../../../hooks/useFetcher'; import { createCallApmApi } from '../../../../../services/rest/createCallApmApi'; import { AgentConfigurationCreateEdit } from './index'; diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx new file mode 100644 index 0000000000000..3a6f94b975800 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx @@ -0,0 +1,157 @@ +/* + * 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 { isEmpty } from 'lodash'; +import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import React, { useState, useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FetcherResult } from '../../../../../hooks/useFetcher'; +import { history } from '../../../../../utils/history'; +import { + AgentConfigurationIntake, + AgentConfiguration +} from '../../../../../../common/agent_configuration/configuration_types'; +import { ServicePage } from './ServicePage/ServicePage'; +import { SettingsPage } from './SettingsPage/SettingsPage'; +import { fromQuery, toQuery } from '../../../../shared/Links/url_helpers'; + +type PageStep = 'choose-service-step' | 'choose-settings-step' | 'review-step'; + +function getInitialNewConfig( + existingConfig: AgentConfigurationIntake | undefined +) { + return { + agent_name: existingConfig?.agent_name, + service: existingConfig?.service || {}, + settings: existingConfig?.settings || {} + }; +} + +function setPage(pageStep: PageStep) { + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + pageStep + }) + }); +} + +function getUnsavedChanges({ + newConfig, + existingConfig +}: { + newConfig: AgentConfigurationIntake; + existingConfig?: AgentConfigurationIntake; +}) { + return Object.fromEntries( + Object.entries(newConfig.settings).filter(([key, value]) => { + const existingValue = existingConfig?.settings?.[key]; + + // don't highlight changes that were added and removed + if (value === '' && existingValue == null) { + return false; + } + + return existingValue !== value; + }) + ); +} + +export function AgentConfigurationCreateEdit({ + pageStep, + existingConfigResult +}: { + pageStep: PageStep; + existingConfigResult?: FetcherResult<AgentConfiguration>; +}) { + const existingConfig = existingConfigResult?.data; + const isEditMode = Boolean(existingConfigResult); + const [newConfig, setNewConfig] = useState<AgentConfigurationIntake>( + getInitialNewConfig(existingConfig) + ); + + const resetSettings = useCallback(() => { + setNewConfig(_newConfig => ({ + ..._newConfig, + settings: existingConfig?.settings || {} + })); + }, [existingConfig]); + + // update newConfig when existingConfig has loaded + useEffect(() => { + setNewConfig(getInitialNewConfig(existingConfig)); + }, [existingConfig]); + + useEffect(() => { + // the user tried to edit the service of an existing config + if (pageStep === 'choose-service-step' && isEditMode) { + setPage('choose-settings-step'); + } + + // the user skipped the first step (select service) + if ( + pageStep === 'choose-settings-step' && + !isEditMode && + isEmpty(newConfig.service) + ) { + setPage('choose-service-step'); + } + }, [isEditMode, newConfig, pageStep]); + + const unsavedChanges = getUnsavedChanges({ newConfig, existingConfig }); + + return ( + <> + <EuiTitle> + <h2> + {isEditMode + ? i18n.translate('xpack.apm.agentConfig.editConfigTitle', { + defaultMessage: 'Edit configuration' + }) + : i18n.translate('xpack.apm.agentConfig.createConfigTitle', { + defaultMessage: 'Create configuration' + })} + </h2> + </EuiTitle> + + <EuiText size="s"> + {i18n.translate('xpack.apm.agentConfig.newConfig.description', { + defaultMessage: `This allows you to fine-tune your agent configuration directly in + Kibana. Best of all, changes are automatically propagated to your APM + agents so there’s no need to redeploy.` + })} + </EuiText> + + <EuiSpacer size="m" /> + + {pageStep === 'choose-service-step' && ( + <ServicePage + newConfig={newConfig} + setNewConfig={setNewConfig} + onClickNext={() => setPage('choose-settings-step')} + /> + )} + + {pageStep === 'choose-settings-step' && ( + <SettingsPage + status={existingConfigResult?.status} + unsavedChanges={unsavedChanges} + onClickEdit={() => setPage('choose-service-step')} + newConfig={newConfig} + setNewConfig={setNewConfig} + resetSettings={resetSettings} + isEditMode={isEditMode} + /> + )} + + {/* + TODO: Add review step + {pageStep === 'review-step' && <div>Review will be here </div>} + */} + </> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx rename to x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx index 267aaddc93f76..6a1a472562305 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -9,8 +9,8 @@ import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import { NotificationsStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AgentConfigurationListAPIResponse } from '../../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; -import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { AgentConfigurationListAPIResponse } from '../../../../../../server/lib/settings/agent_configuration/list_configurations'; +import { getOptionLabel } from '../../../../../../common/agent_configuration/all_option'; import { callApmApi } from '../../../../../services/rest/createCallApmApi'; import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx new file mode 100644 index 0000000000000..9eaa7786baca0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.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, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiEmptyPrompt, + EuiButton, + EuiButtonEmpty, + EuiHealth, + EuiToolTip, + EuiButtonIcon +} from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { ITableColumn, ManagedTable } from '../../../../shared/ManagedTable'; +import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AgentConfigurationListAPIResponse } from '../../../../../../server/lib/settings/agent_configuration/list_configurations'; +import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; +import { px, units } from '../../../../../style/variables'; +import { getOptionLabel } from '../../../../../../common/agent_configuration/all_option'; +import { + createAgentConfigurationHref, + editAgentConfigurationHref +} from '../../../../shared/Links/apm/agentConfigurationLinks'; +import { ConfirmDeleteModal } from './ConfirmDeleteModal'; + +type Config = AgentConfigurationListAPIResponse[0]; + +export function AgentConfigurationList({ + status, + data, + refetch +}: { + status: FETCH_STATUS; + data: Config[]; + refetch: () => void; +}) { + const [configToBeDeleted, setConfigToBeDeleted] = useState<Config | null>( + null + ); + + const emptyStatePrompt = ( + <EuiEmptyPrompt + iconType="controlsHorizontal" + title={ + <h2> + {i18n.translate( + 'xpack.apm.agentConfig.configTable.emptyPromptTitle', + { defaultMessage: 'No configurations found.' } + )} + </h2> + } + body={ + <> + <p> + {i18n.translate( + 'xpack.apm.agentConfig.configTable.emptyPromptText', + { + defaultMessage: + "Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration." + } + )} + </p> + </> + } + actions={ + <EuiButton color="primary" fill href={createAgentConfigurationHref()}> + {i18n.translate( + 'xpack.apm.agentConfig.configTable.createConfigButtonLabel', + { defaultMessage: 'Create configuration' } + )} + </EuiButton> + } + /> + ); + + const failurePrompt = ( + <EuiEmptyPrompt + iconType="alert" + body={ + <> + <p> + {i18n.translate( + 'xpack.apm.agentConfig.configTable.configTable.failurePromptText', + { + defaultMessage: + 'The list of agent configurations could not be fetched. Your user may not have the sufficient permissions.' + } + )} + </p> + </> + } + /> + ); + + if (status === FETCH_STATUS.FAILURE) { + return failurePrompt; + } + + if (status === FETCH_STATUS.SUCCESS && isEmpty(data)) { + return emptyStatePrompt; + } + + const columns: Array<ITableColumn<Config>> = [ + { + field: 'applied_by_agent', + align: 'center', + width: px(units.double), + name: '', + sortable: true, + render: (isApplied: boolean) => ( + <EuiToolTip + content={ + isApplied + ? i18n.translate( + 'xpack.apm.agentConfig.configTable.appliedTooltipMessage', + { defaultMessage: 'Applied by at least one agent' } + ) + : i18n.translate( + 'xpack.apm.agentConfig.configTable.notAppliedTooltipMessage', + { defaultMessage: 'Not yet applied by any agents' } + ) + } + > + <EuiHealth color={isApplied ? 'success' : theme.euiColorLightShade} /> + </EuiToolTip> + ) + }, + { + field: 'service.name', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.serviceNameColumnLabel', + { defaultMessage: 'Service name' } + ), + sortable: true, + render: (_, config: Config) => ( + <EuiButtonEmpty + flush="left" + size="s" + color="primary" + href={editAgentConfigurationHref(config.service)} + > + {getOptionLabel(config.service.name)} + </EuiButtonEmpty> + ) + }, + { + field: 'service.environment', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.environmentColumnLabel', + { defaultMessage: 'Service environment' } + ), + sortable: true, + render: (environment: string) => getOptionLabel(environment) + }, + { + align: 'right', + field: '@timestamp', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.lastUpdatedColumnLabel', + { defaultMessage: 'Last updated' } + ), + sortable: true, + render: (value: number) => ( + <TimestampTooltip time={value} timeUnit="minutes" /> + ) + }, + { + width: px(units.double), + name: '', + render: (config: Config) => ( + <EuiButtonIcon + aria-label="Edit" + iconType="pencil" + href={editAgentConfigurationHref(config.service)} + /> + ) + }, + { + width: px(units.double), + name: '', + render: (config: Config) => ( + <EuiButtonIcon + aria-label="Delete" + iconType="trash" + onClick={() => setConfigToBeDeleted(config)} + /> + ) + } + ]; + + return ( + <> + {configToBeDeleted && ( + <ConfirmDeleteModal + config={configToBeDeleted} + onCancel={() => setConfigToBeDeleted(null)} + onConfirm={() => { + setConfigToBeDeleted(null); + refetch(); + }} + /> + )} + + <ManagedTable + noItemsMessage={<LoadingStatePrompt />} + columns={columns} + items={data} + initialSortField="service.name" + initialSortDirection="asc" + initialPageSize={20} + /> + </> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx new file mode 100644 index 0000000000000..4349e542449cc --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -0,0 +1,77 @@ +/* + * 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 { + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiButton +} from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import { useFetcher } from '../../../../hooks/useFetcher'; +import { AgentConfigurationList } from './List'; +import { useTrackPageview } from '../../../../../../observability/public'; +import { createAgentConfigurationHref } from '../../../shared/Links/apm/agentConfigurationLinks'; + +export function AgentConfigurations() { + const { refetch, data = [], status } = useFetcher( + callApmApi => + callApmApi({ pathname: '/api/apm/settings/agent-configuration' }), + [], + { preservePreviousData: false } + ); + + useTrackPageview({ app: 'apm', path: 'agent_configuration' }); + useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 }); + + const hasConfigurations = !isEmpty(data); + + return ( + <> + <EuiPanel> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <EuiTitle> + <h2> + {i18n.translate( + 'xpack.apm.agentConfig.configurationsPanelTitle', + { defaultMessage: 'Agent remote configuration' } + )} + </h2> + </EuiTitle> + </EuiFlexItem> + + {hasConfigurations ? <CreateConfigurationButton /> : null} + </EuiFlexGroup> + + <EuiSpacer size="m" /> + + <AgentConfigurationList status={status} data={data} refetch={refetch} /> + </EuiPanel> + </> + ); +} + +function CreateConfigurationButton() { + const href = createAgentConfigurationHref(); + return ( + <EuiFlexItem> + <EuiFlexGroup alignItems="center" justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <EuiButton color="primary" fill iconType="plusInCircle" href={href}> + {i18n.translate('xpack.apm.agentConfig.createConfigButtonLabel', { + defaultMessage: 'Create configuration' + })} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx new file mode 100644 index 0000000000000..b03960861e0ad --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { ApmIndices } from '.'; +import * as hooks from '../../../../hooks/useFetcher'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; + +describe('ApmIndices', () => { + it('should not get stuck in infinite loop', () => { + const spy = spyOn(hooks, 'useFetcher').and.returnValue({ + data: undefined, + status: 'loading' + }); + const { getByText } = render( + <MockApmPluginContextWrapper> + <ApmIndices /> + </MockApmPluginContextWrapper> + ); + + expect(getByText('Indices')).toMatchInlineSnapshot(` + <h2 + class="euiTitle euiTitle--medium" + > + Indices + </h2> + `); + + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx rename to x-pack/plugins/apm/public/components/app/Settings/ApmIndices/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateCustomLinkButton.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/DeleteButton.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/Documentation.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx similarity index 98% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx index fb8ffe6722c87..9c244e3cde411 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FiltersSection.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { Filter, FilterKey -} from '../../../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; +} from '../../../../../../../common/custom_link/custom_link_types'; import { DEFAULT_OPTION, FILTER_SELECT_OPTIONS, diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/FlyoutFooter.tsx diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx new file mode 100644 index 0000000000000..a1afbd7a807cc --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.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 { LinkPreview } from '../CustomLinkFlyout/LinkPreview'; +import { + render, + getNodeText, + getByTestId, + act, + wait +} from '@testing-library/react'; +import * as apmApi from '../../../../../../services/rest/createCallApmApi'; + +describe('LinkPreview', () => { + let callApmApiSpy: jasmine.Spy; + beforeAll(() => { + callApmApiSpy = spyOn(apmApi, 'callApmApi').and.returnValue({ + transaction: { id: 'foo' } + }); + }); + afterAll(() => { + jest.clearAllMocks(); + }); + const getElementValue = (container: HTMLElement, id: string) => + getNodeText( + ((getByTestId(container, id) as HTMLDivElement) + .children as HTMLCollection)[0] as HTMLDivElement + ); + + it('shows label and url default values', () => { + act(() => { + const { container } = render( + <LinkPreview label="" url="" filters={[{ key: '', value: '' }]} /> + ); + expect(getElementValue(container, 'preview-label')).toEqual('Elastic.co'); + expect(getElementValue(container, 'preview-url')).toEqual( + 'https://www.elastic.co' + ); + }); + }); + + it('shows label and url values', () => { + act(() => { + const { container } = render( + <LinkPreview + label="foo" + url="https://baz.co" + filters={[{ key: '', value: '' }]} + /> + ); + expect(getElementValue(container, 'preview-label')).toEqual('foo'); + expect( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ).toEqual('https://baz.co'); + }); + }); + + it("shows warning when couldn't replace context variables", () => { + act(() => { + const { container } = render( + <LinkPreview + label="foo" + url="https://baz.co?service.name={{invalid}" + filters={[{ key: '', value: '' }]} + /> + ); + expect(getElementValue(container, 'preview-label')).toEqual('foo'); + expect( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ).toEqual('https://baz.co?service.name={{invalid}'); + expect(getByTestId(container, 'preview-warning')).toBeInTheDocument(); + }); + }); + it('replaces url with transaction id', async () => { + const { container } = render( + <LinkPreview + label="foo" + url="https://baz.co?transaction={{transaction.id}}" + filters={[{ key: '', value: '' }]} + /> + ); + await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); + expect(getElementValue(container, 'preview-label')).toEqual('foo'); + expect( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ).toEqual('https://baz.co?transaction=foo'); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx similarity index 94% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx index 8edfb176a1af8..8fed838a48261 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.tsx @@ -17,8 +17,8 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { debounce } from 'lodash'; -import { Filter } from '../../../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Filter } from '../../../../../../../common/custom_link/custom_link_types'; +import { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction'; import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; import { replaceTemplateVariables, convertFiltersToQuery } from './helper'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx index 630f7148ad408..210033888d90c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkSection.tsx @@ -12,7 +12,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { CustomLink } from '../../../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; +import { CustomLink } from '../../../../../../../common/custom_link/custom_link_types'; import { Documentation } from './Documentation'; interface InputField { diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts new file mode 100644 index 0000000000000..49e381aab675d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.test.ts @@ -0,0 +1,147 @@ +/* + * 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 { + getSelectOptions, + replaceTemplateVariables +} from '../CustomLinkFlyout/helper'; +import { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction'; + +describe('Custom link helper', () => { + describe('getSelectOptions', () => { + it('returns all available options when no filters were selected', () => { + expect( + getSelectOptions( + [ + { key: '', value: '' }, + { key: '', value: '' }, + { key: '', value: '' }, + { key: '', value: '' } + ], + '' + ) + ).toEqual([ + { value: 'DEFAULT', text: 'Select field...' }, + { value: 'service.name', text: 'service.name' }, + { value: 'service.environment', text: 'service.environment' }, + { value: 'transaction.type', text: 'transaction.type' }, + { value: 'transaction.name', text: 'transaction.name' } + ]); + }); + it('removes item added in another filter', () => { + expect( + getSelectOptions( + [ + { key: 'service.name', value: 'foo' }, + { key: '', value: '' }, + { key: '', value: '' }, + { key: '', value: '' } + ], + '' + ) + ).toEqual([ + { value: 'DEFAULT', text: 'Select field...' }, + { value: 'service.environment', text: 'service.environment' }, + { value: 'transaction.type', text: 'transaction.type' }, + { value: 'transaction.name', text: 'transaction.name' } + ]); + }); + it('removes item added in another filter but keep the current selected', () => { + expect( + getSelectOptions( + [ + { key: 'service.name', value: 'foo' }, + { key: 'transaction.name', value: 'bar' }, + { key: '', value: '' }, + { key: '', value: '' } + ], + 'transaction.name' + ) + ).toEqual([ + { value: 'DEFAULT', text: 'Select field...' }, + { value: 'service.environment', text: 'service.environment' }, + { value: 'transaction.type', text: 'transaction.type' }, + { value: 'transaction.name', text: 'transaction.name' } + ]); + }); + it('returns empty when all option were selected', () => { + expect( + getSelectOptions( + [ + { key: 'service.name', value: 'foo' }, + { key: 'transaction.name', value: 'bar' }, + { key: 'service.environment', value: 'baz' }, + { key: 'transaction.type', value: 'qux' } + ], + '' + ) + ).toEqual([{ value: 'DEFAULT', text: 'Select field...' }]); + }); + }); + + describe('replaceTemplateVariables', () => { + const transaction = ({ + service: { name: 'foo' }, + trace: { id: '123' } + } as unknown) as Transaction; + + it('replaces template variables', () => { + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}', + transaction + ) + ).toEqual({ + error: undefined, + formattedUrl: 'https://elastic.co?service.name=foo&trace.id=123' + }); + }); + + it('returns error when transaction is not defined', () => { + const expectedResult = { + error: + "We couldn't find a matching transaction document based on the defined filters.", + formattedUrl: 'https://elastic.co?service.name=&trace.id=' + }; + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}' + ) + ).toEqual(expectedResult); + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}}&trace.id={{trace.id}}', + ({} as unknown) as Transaction + ) + ).toEqual(expectedResult); + }); + + it('returns error when could not replace variables', () => { + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.nam}}&trace.id={{trace.i}}', + transaction + ) + ).toEqual({ + error: + "We couldn't find a value match for {{service.nam}}, {{trace.i}} in the example transaction document.", + formattedUrl: 'https://elastic.co?service.name=&trace.id=' + }); + }); + + it('returns error when variable is invalid', () => { + expect( + replaceTemplateVariables( + 'https://elastic.co?service.name={{service.name}', + transaction + ) + ).toEqual({ + error: + "We couldn't find an example transaction document due to invalid variable(s) defined.", + formattedUrl: 'https://elastic.co?service.name={{service.name}' + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts new file mode 100644 index 0000000000000..8c35b8fe77506 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/helper.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import Mustache from 'mustache'; +import { isEmpty, get } from 'lodash'; +import { FILTER_OPTIONS } from '../../../../../../../common/custom_link/custom_link_filter_options'; +import { + Filter, + FilterKey +} from '../../../../../../../common/custom_link/custom_link_types'; +import { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction'; + +interface FilterSelectOption { + value: 'DEFAULT' | FilterKey; + text: string; +} + +export const DEFAULT_OPTION: FilterSelectOption = { + value: 'DEFAULT', + text: i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyOut.filters.defaultOption', + { defaultMessage: 'Select field...' } + ) +}; + +export const FILTER_SELECT_OPTIONS: FilterSelectOption[] = [ + DEFAULT_OPTION, + ...FILTER_OPTIONS.map(filter => ({ + value: filter, + text: filter + })) +]; + +/** + * Returns the options available, removing filters already added, but keeping the selected filter. + * + * @param filters + * @param selectedKey + */ +export const getSelectOptions = ( + filters: Filter[], + selectedKey: Filter['key'] +) => { + return FILTER_SELECT_OPTIONS.filter( + ({ value }) => + !filters.some(({ key }) => key === value && key !== selectedKey) + ); +}; + +const getInvalidTemplateVariables = ( + template: string, + transaction: Transaction +) => { + return (Mustache.parse(template) as Array<[string, string]>) + .filter(([type]) => type === 'name') + .map(([, value]) => value) + .filter(templateVar => get(transaction, templateVar) == null); +}; + +const validateUrl = (url: string, transaction?: Transaction) => { + if (!transaction || isEmpty(transaction)) { + return i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.preview.transaction.notFound', + { + defaultMessage: + "We couldn't find a matching transaction document based on the defined filters." + } + ); + } + try { + const invalidVariables = getInvalidTemplateVariables(url, transaction); + if (!isEmpty(invalidVariables)) { + return i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.preview.contextVariable.noMatch', + { + defaultMessage: + "We couldn't find a value match for {variables} in the example transaction document.", + values: { + variables: invalidVariables + .map(variable => `{{${variable}}}`) + .join(', ') + } + } + ); + } + } catch (e) { + return i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.preview.contextVariable.invalid', + { + defaultMessage: + "We couldn't find an example transaction document due to invalid variable(s) defined." + } + ); + } +}; + +export const replaceTemplateVariables = ( + url: string, + transaction?: Transaction +) => { + const error = validateUrl(url, transaction); + try { + return { formattedUrl: Mustache.render(url, transaction), error }; + } catch (e) { + // errors will be caught on validateUrl function + return { formattedUrl: url, error }; + } +}; + +export const convertFiltersToQuery = (filters: Filter[]) => { + return filters.reduce((acc: Record<string, string>, { key, value }) => { + if (key && value) { + acc[key] = value; + } + return acc; + }, {}); +}; diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx new file mode 100644 index 0000000000000..150147d9af405 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/index.tsx @@ -0,0 +1,142 @@ +/* + * 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 { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiPortal, + EuiSpacer, + EuiText, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { Filter } from '../../../../../../../common/custom_link/custom_link_types'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; +import { FiltersSection } from './FiltersSection'; +import { FlyoutFooter } from './FlyoutFooter'; +import { LinkSection } from './LinkSection'; +import { saveCustomLink } from './saveCustomLink'; +import { LinkPreview } from './LinkPreview'; +import { Documentation } from './Documentation'; + +interface Props { + onClose: () => void; + onSave: () => void; + onDelete: () => void; + defaults?: { + url?: string; + label?: string; + filters?: Filter[]; + }; + customLinkId?: string; +} + +const filtersEmptyState: Filter[] = [{ key: '', value: '' }]; + +export const CustomLinkFlyout = ({ + onClose, + onSave, + onDelete, + defaults, + customLinkId +}: Props) => { + const { toasts } = useApmPluginContext().core.notifications; + const [isSaving, setIsSaving] = useState(false); + + const [label, setLabel] = useState(defaults?.label || ''); + const [url, setUrl] = useState(defaults?.url || ''); + const [filters, setFilters] = useState( + defaults?.filters?.length ? defaults.filters : filtersEmptyState + ); + + const isFormValid = !!label && !!url; + + const onSubmit = async ( + event: + | React.FormEvent<HTMLFormElement> + | React.MouseEvent<HTMLButtonElement> + ) => { + event.preventDefault(); + setIsSaving(true); + await saveCustomLink({ + id: customLinkId, + label, + url, + filters, + toasts + }); + setIsSaving(false); + onSave(); + }; + + return ( + <EuiPortal> + <form onSubmit={onSubmit}> + <EuiFlyout ownFocus onClose={onClose} size="m"> + <EuiFlyoutHeader hasBorder> + <EuiTitle size="s"> + <h2> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.title', + { + defaultMessage: 'Create link' + } + )} + </h2> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <EuiText> + <p> + {i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.label', + { + defaultMessage: + 'Links will be available in the context of transaction details throughout the APM app. You can create an unlimited number of links. You can refer to dynamic variables by using any of the transaction metadata to fill in your URLs. More information, including examples, are available in the' + } + )}{' '} + <Documentation + label={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.flyout.label.doc', + { + defaultMessage: 'documentation.' + } + )} + /> + </p> + </EuiText> + + <EuiSpacer size="l" /> + + <LinkSection + label={label} + onChangeLabel={setLabel} + url={url} + onChangeUrl={setUrl} + /> + + <EuiSpacer size="l" /> + + <FiltersSection filters={filters} onChangeFilters={setFilters} /> + + <EuiSpacer size="l" /> + + <LinkPreview label={label} url={url} filters={filters} /> + </EuiFlyoutBody> + + <FlyoutFooter + isSaveButtonEnabled={isFormValid} + onClose={onClose} + isSaving={isSaving} + onDelete={onDelete} + customLinkId={customLinkId} + /> + </EuiFlyout> + </form> + </EuiPortal> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts index 9cbaf16320a6b..685b3ab022950 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/saveCustomLink.ts @@ -9,7 +9,7 @@ import { NotificationsStart } from 'kibana/public'; import { Filter, CustomLink -} from '../../../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; +} from '../../../../../../../common/custom_link/custom_link_types'; import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; export async function saveCustomLink({ diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx index 68e6ee52af0b0..d68fb757e53d1 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkTable.tsx @@ -13,7 +13,7 @@ import { EuiSpacer } from '@elastic/eui'; import { isEmpty } from 'lodash'; -import { CustomLink } from '../../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; +import { CustomLink } from '../../../../../../common/custom_link/custom_link_types'; import { units, px } from '../../../../../style/variables'; import { ManagedTable } from '../../../../shared/ManagedTable'; import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/EmptyPrompt.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/Title.tsx diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx new file mode 100644 index 0000000000000..32a08f5ffaf7c --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -0,0 +1,357 @@ +/* + * 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 { fireEvent, render, wait, RenderResult } from '@testing-library/react'; +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import * as apmApi from '../../../../../services/rest/createCallApmApi'; +import { License } from '../../../../../../../licensing/common/license'; +import * as hooks from '../../../../../hooks/useFetcher'; +import { LicenseContext } from '../../../../../context/LicenseContext'; +import { CustomLinkOverview } from '.'; +import { + expectTextsInDocument, + expectTextsNotInDocument +} from '../../../../../utils/testHelpers'; +import * as saveCustomLink from './CustomLinkFlyout/saveCustomLink'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; + +const data = [ + { + id: '1', + label: 'label 1', + url: 'url 1', + 'service.name': 'opbeans-java' + }, + { + id: '2', + label: 'label 2', + url: 'url 2', + 'transaction.type': 'request' + } +]; + +describe('CustomLink', () => { + let callApmApiSpy: jasmine.Spy; + beforeAll(() => { + callApmApiSpy = spyOn(apmApi, 'callApmApi').and.returnValue({}); + }); + afterAll(() => { + jest.resetAllMocks(); + }); + const goldLicense = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'active', + type: 'gold', + uid: '1' + } + }); + describe('empty prompt', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data: [], + status: 'success' + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + it('shows when no link is available', () => { + const component = render( + <LicenseContext.Provider value={goldLicense}> + <CustomLinkOverview /> + </LicenseContext.Provider> + ); + expectTextsInDocument(component, ['No links found.']); + }); + }); + + describe('overview', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data, + status: 'success' + }); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('shows a table with all custom link', () => { + const component = render( + <LicenseContext.Provider value={goldLicense}> + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + expectTextsInDocument(component, [ + 'label 1', + 'url 1', + 'label 2', + 'url 2' + ]); + }); + + it('checks if create custom link button is available and working', async () => { + const { queryByText, getByText } = render( + <LicenseContext.Provider value={goldLicense}> + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + expect(queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(getByText('Create custom link')); + }); + await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); + expect(queryByText('Create link')).toBeInTheDocument(); + }); + }); + + describe('Flyout', () => { + const refetch = jest.fn(); + let saveCustomLinkSpy: Function; + beforeAll(() => { + saveCustomLinkSpy = spyOn(saveCustomLink, 'saveCustomLink'); + spyOn(hooks, 'useFetcher').and.returnValue({ + data, + status: 'success', + refetch + }); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + const openFlyout = async () => { + const component = render( + <LicenseContext.Provider value={goldLicense}> + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + expect(component.queryByText('Create link')).not.toBeInTheDocument(); + act(() => { + fireEvent.click(component.getByText('Create custom link')); + }); + await wait(() => + expect(component.queryByText('Create link')).toBeInTheDocument() + ); + await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); + return component; + }; + + it('creates a custom link', async () => { + const component = await openFlyout(); + const labelInput = component.getByTestId('label'); + act(() => { + fireEvent.change(labelInput, { + target: { value: 'foo' } + }); + }); + const urlInput = component.getByTestId('url'); + act(() => { + fireEvent.change(urlInput, { + target: { value: 'bar' } + }); + }); + await act(async () => { + await wait(() => fireEvent.submit(component.getByText('Save'))); + }); + expect(saveCustomLinkSpy).toHaveBeenCalledTimes(1); + }); + + it('deletes a custom link', async () => { + const component = render( + <LicenseContext.Provider value={goldLicense}> + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + expect(component.queryByText('Create link')).not.toBeInTheDocument(); + const editButtons = component.getAllByLabelText('Edit'); + expect(editButtons.length).toEqual(2); + act(() => { + fireEvent.click(editButtons[0]); + }); + expect(component.queryByText('Create link')).toBeInTheDocument(); + await act(async () => { + await wait(() => fireEvent.click(component.getByText('Delete'))); + }); + expect(callApmApiSpy).toHaveBeenCalled(); + expect(refetch).toHaveBeenCalled(); + }); + + describe('Filters', () => { + const addFilterField = (component: RenderResult, amount: number) => { + for (let i = 1; i <= amount; i++) { + fireEvent.click(component.getByText('Add another filter')); + } + }; + it('checks if add filter button is disabled after all elements have been added', async () => { + const component = await openFlyout(); + expect(component.getAllByText('service.name').length).toEqual(1); + addFilterField(component, 1); + expect(component.getAllByText('service.name').length).toEqual(2); + addFilterField(component, 2); + expect(component.getAllByText('service.name').length).toEqual(4); + // After 4 items, the button is disabled + addFilterField(component, 2); + expect(component.getAllByText('service.name').length).toEqual(4); + }); + it('removes items already selected', async () => { + const component = await openFlyout(); + + const addFieldAndCheck = ( + fieldName: string, + selectValue: string, + addNewFilter: boolean, + optionsExpected: string[] + ) => { + if (addNewFilter) { + addFilterField(component, 1); + } + const field = component.getByTestId(fieldName) as HTMLSelectElement; + const optionsAvailable = Object.values(field) + .map(option => (option as HTMLOptionElement).text) + .filter(option => option); + + act(() => { + fireEvent.change(field, { + target: { value: selectValue } + }); + }); + expect(field.value).toEqual(selectValue); + expect(optionsAvailable).toEqual(optionsExpected); + }; + + addFieldAndCheck('filter-0', 'transaction.name', false, [ + 'Select field...', + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name' + ]); + + addFieldAndCheck('filter-1', 'service.name', true, [ + 'Select field...', + 'service.name', + 'service.environment', + 'transaction.type' + ]); + + addFieldAndCheck('filter-2', 'transaction.type', true, [ + 'Select field...', + 'service.environment', + 'transaction.type' + ]); + + addFieldAndCheck('filter-3', 'service.environment', true, [ + 'Select field...', + 'service.environment' + ]); + }); + }); + }); + + describe('invalid license', () => { + beforeAll(() => { + spyOn(hooks, 'useFetcher').and.returnValue({ + data: [], + status: 'success' + }); + }); + it('shows license prompt when user has a basic license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'basic', + status: 'active', + type: 'basic', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + expectTextsInDocument(component, ['Start free 30-day trial']); + }); + it('shows license prompt when user has an invalid gold license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'gold', + status: 'invalid', + type: 'gold', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + expectTextsInDocument(component, ['Start free 30-day trial']); + }); + it('shows license prompt when user has an invalid trial license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'trial', + status: 'invalid', + type: 'trial', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + expectTextsInDocument(component, ['Start free 30-day trial']); + }); + it('doesnt show license prompt when user has a trial license', () => { + const license = new License({ + signature: 'test signature', + license: { + expiryDateInMillis: 0, + mode: 'trial', + status: 'active', + type: 'trial', + uid: '1' + } + }); + const component = render( + <LicenseContext.Provider value={license}> + <MockApmPluginContextWrapper> + <CustomLinkOverview /> + </MockApmPluginContextWrapper> + </LicenseContext.Provider> + ); + expectTextsNotInDocument(component, ['Start free 30-day trial']); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.tsx new file mode 100644 index 0000000000000..b94ce513bc210 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.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 { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { CustomLink } from '../../../../../../common/custom_link/custom_link_types'; +import { useLicense } from '../../../../../hooks/useLicense'; +import { useFetcher, FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { CustomLinkFlyout } from './CustomLinkFlyout'; +import { CustomLinkTable } from './CustomLinkTable'; +import { EmptyPrompt } from './EmptyPrompt'; +import { Title } from './Title'; +import { CreateCustomLinkButton } from './CreateCustomLinkButton'; +import { LicensePrompt } from '../../../../shared/LicensePrompt'; + +export const CustomLinkOverview = () => { + const license = useLicense(); + const hasValidLicense = license?.isActive && license?.hasAtLeast('gold'); + + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); + const [customLinkSelected, setCustomLinkSelected] = useState< + CustomLink | undefined + >(); + + const { data: customLinks, status, refetch } = useFetcher( + callApmApi => callApmApi({ pathname: '/api/apm/settings/custom_links' }), + [] + ); + + useEffect(() => { + if (customLinkSelected) { + setIsFlyoutOpen(true); + } + }, [customLinkSelected]); + + const onCloseFlyout = () => { + setCustomLinkSelected(undefined); + setIsFlyoutOpen(false); + }; + + const onCreateCustomLinkClick = () => { + setIsFlyoutOpen(true); + }; + + const showEmptyPrompt = + status === FETCH_STATUS.SUCCESS && isEmpty(customLinks); + + return ( + <> + {isFlyoutOpen && ( + <CustomLinkFlyout + onClose={onCloseFlyout} + defaults={customLinkSelected} + customLinkId={customLinkSelected?.id} + onSave={() => { + onCloseFlyout(); + refetch(); + }} + onDelete={() => { + onCloseFlyout(); + refetch(); + }} + /> + )} + <EuiPanel> + <EuiFlexGroup alignItems="center"> + <EuiFlexItem grow={false}> + <Title /> + </EuiFlexItem> + {hasValidLicense && !showEmptyPrompt && ( + <EuiFlexItem> + <EuiFlexGroup alignItems="center" justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <CreateCustomLinkButton onClick={onCreateCustomLinkClick} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + )} + </EuiFlexGroup> + + <EuiSpacer size="m" /> + {hasValidLicense ? ( + showEmptyPrompt ? ( + <EmptyPrompt onCreateCustomLinkClick={onCreateCustomLinkClick} /> + ) : ( + <CustomLinkTable + items={customLinks} + onCustomLinkSelected={setCustomLinkSelected} + /> + ) + ) : ( + <LicensePrompt + text={i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.license.text', + { + defaultMessage: + "To create custom links, you must be subscribed to an Elastic Gold license or above. With it, you'll have the ability to create custom links to improve your workflow when analyzing your services." + } + )} + /> + )} + </EuiPanel> + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx rename to x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx rename to x-pack/plugins/apm/public/components/app/Settings/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx b/x-pack/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx rename to x-pack/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx b/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx new file mode 100644 index 0000000000000..04d830f4649d4 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TraceLink/index.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiEmptyPrompt } from '@elastic/eui'; +import React from 'react'; +import { Redirect } from 'react-router-dom'; +import styled from 'styled-components'; +import url from 'url'; +import { TRACE_ID } from '../../../../common/elasticsearch_fieldnames'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; +import { useUrlParams } from '../../../hooks/useUrlParams'; + +const CentralizedContainer = styled.div` + height: 100%; + display: flex; +`; + +const redirectToTransactionDetailPage = ({ + transaction, + rangeFrom, + rangeTo +}: { + transaction: Transaction; + rangeFrom?: string; + rangeTo?: string; +}) => + url.format({ + pathname: `/services/${transaction.service.name}/transactions/view`, + query: { + traceId: transaction.trace.id, + transactionId: transaction.transaction.id, + transactionName: transaction.transaction.name, + transactionType: transaction.transaction.type, + rangeFrom, + rangeTo + } + }); + +const redirectToTracePage = ({ + traceId, + rangeFrom, + rangeTo +}: { + traceId: string; + rangeFrom?: string; + rangeTo?: string; +}) => + url.format({ + pathname: `/traces`, + query: { + kuery: encodeURIComponent(`${TRACE_ID} : "${traceId}"`), + rangeFrom, + rangeTo + } + }); + +export const TraceLink = () => { + const { urlParams } = useUrlParams(); + const { traceIdLink: traceId, rangeFrom, rangeTo } = urlParams; + + const { data = { transaction: null }, status } = useFetcher( + callApmApi => { + if (traceId) { + return callApmApi({ + pathname: '/api/apm/transaction/{traceId}', + params: { + path: { + traceId + } + } + }); + } + }, + [traceId] + ); + if (traceId && status === FETCH_STATUS.SUCCESS) { + const to = data.transaction + ? redirectToTransactionDetailPage({ + transaction: data.transaction, + rangeFrom, + rangeTo + }) + : redirectToTracePage({ traceId, rangeFrom, rangeTo }); + return <Redirect to={to} />; + } + + return ( + <CentralizedContainer> + <EuiEmptyPrompt iconType="apmTrace" title={<h2>Fetching trace...</h2>} /> + </CentralizedContainer> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TraceOverview/TraceList.tsx b/x-pack/plugins/apm/public/components/app/TraceOverview/TraceList.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/app/TraceOverview/TraceList.tsx rename to x-pack/plugins/apm/public/components/app/TraceOverview/TraceList.tsx index 91f3051acf077..92d5a38cc11ca 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TraceOverview/TraceList.tsx +++ b/x-pack/plugins/apm/public/components/app/TraceOverview/TraceList.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import styled from 'styled-components'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ITransactionGroup } from '../../../../../../../plugins/apm/server/lib/transaction_groups/transform'; +import { ITransactionGroup } from '../../../../server/lib/transaction_groups/transform'; import { fontSizes, truncate } from '../../../style/variables'; import { convertTo } from '../../../utils/formatters'; import { EmptyMessage } from '../../shared/EmptyMessage'; diff --git a/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx new file mode 100644 index 0000000000000..a7fa927f9e9b1 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TraceOverview/index.tsx @@ -0,0 +1,67 @@ +/* + * 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 { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; +import { TraceList } from './TraceList'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useTrackPageview } from '../../../../../observability/public'; +import { LocalUIFilters } from '../../shared/LocalUIFilters'; +import { PROJECTION } from '../../../../common/projections/typings'; + +export function TraceOverview() { + const { urlParams, uiFilters } = useUrlParams(); + const { start, end } = urlParams; + const { status, data = [] } = useFetcher( + callApmApi => { + if (start && end) { + return callApmApi({ + pathname: '/api/apm/traces', + params: { + query: { + start, + end, + uiFilters: JSON.stringify(uiFilters) + } + } + }); + } + }, + [start, end, uiFilters] + ); + + useTrackPageview({ app: 'apm', path: 'traces_overview' }); + useTrackPageview({ app: 'apm', path: 'traces_overview', delay: 15000 }); + + const localUIFiltersConfig = useMemo(() => { + const config: React.ComponentProps<typeof LocalUIFilters> = { + filterNames: ['transactionResult', 'host', 'containerId', 'podName'], + projection: PROJECTION.TRACES + }; + + return config; + }, []); + + return ( + <> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem grow={1}> + <LocalUIFilters {...localUIFiltersConfig} showCount={false} /> + </EuiFlexItem> + <EuiFlexItem grow={7}> + <EuiPanel> + <TraceList + items={data} + isLoading={status === FETCH_STATUS.LOADING} + /> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/__test__/distribution.test.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/__test__/distribution.test.ts similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/__test__/distribution.test.ts rename to x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/__test__/distribution.test.ts index 08682fb3be842..7ad0a77505b9d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/Distribution/__test__/distribution.test.ts +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/__test__/distribution.test.ts @@ -6,7 +6,7 @@ import { getFormattedBuckets } from '../index'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IBucket } from '../../../../../../../../../plugins/apm/server/lib/transactions/distribution/get_buckets/transform'; +import { IBucket } from '../../../../../../server/lib/transactions/distribution/get_buckets/transform'; describe('Distribution', () => { it('getFormattedBuckets', () => { diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx new file mode 100644 index 0000000000000..b7dbfbdbd7d7e --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -0,0 +1,212 @@ +/* + * 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 { EuiIconTip, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import d3 from 'd3'; +import React, { FunctionComponent, useCallback } from 'react'; +import { isEmpty } from 'lodash'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { TransactionDistributionAPIResponse } from '../../../../../server/lib/transactions/distribution'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IBucket } from '../../../../../server/lib/transactions/distribution/get_buckets/transform'; +import { IUrlParams } from '../../../../context/UrlParamsContext/types'; +import { getDurationFormatter } from '../../../../utils/formatters'; +// @ts-ignore +import Histogram from '../../../shared/charts/Histogram'; +import { EmptyMessage } from '../../../shared/EmptyMessage'; +import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; +import { history } from '../../../../utils/history'; +import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; + +interface IChartPoint { + samples: IBucket['samples']; + x0: number; + x: number; + y: number; + style: { + cursor: string; + }; +} + +export function getFormattedBuckets(buckets: IBucket[], bucketSize: number) { + if (!buckets) { + return []; + } + + return buckets.map( + ({ samples, count, key }): IChartPoint => { + return { + samples, + x0: key, + x: key + bucketSize, + y: count, + style: { + cursor: isEmpty(samples) ? 'default' : 'pointer' + } + }; + } + ); +} + +const getFormatYShort = (transactionType: string | undefined) => ( + t: number +) => { + return i18n.translate( + 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.unitShortLabel', + { + defaultMessage: + '{transCount} {transType, select, request {req.} other {trans.}}', + values: { + transCount: t, + transType: transactionType + } + } + ); +}; + +const getFormatYLong = (transactionType: string | undefined) => (t: number) => { + return transactionType === 'request' + ? i18n.translate( + 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel', + { + defaultMessage: + '{transCount, plural, =0 {# request} one {# request} other {# requests}}', + values: { + transCount: t + } + } + ) + : i18n.translate( + 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel', + { + defaultMessage: + '{transCount, plural, =0 {# transaction} one {# transaction} other {# transactions}}', + values: { + transCount: t + } + } + ); +}; + +interface Props { + distribution?: TransactionDistributionAPIResponse; + urlParams: IUrlParams; + isLoading: boolean; + bucketIndex: number; +} + +export const TransactionDistribution: FunctionComponent<Props> = ( + props: Props +) => { + const { + distribution, + urlParams: { transactionType }, + isLoading, + bucketIndex + } = props; + + const formatYShort = useCallback(getFormatYShort(transactionType), [ + transactionType + ]); + + const formatYLong = useCallback(getFormatYLong(transactionType), [ + transactionType + ]); + + // no data in response + if (!distribution || distribution.noHits) { + // only show loading state if there is no data - else show stale data until new data has loaded + if (isLoading) { + return <LoadingStatePrompt />; + } + + return ( + <EmptyMessage + heading={i18n.translate('xpack.apm.transactionDetails.notFoundLabel', { + defaultMessage: 'No transactions were found.' + })} + /> + ); + } + + const buckets = getFormattedBuckets( + distribution.buckets, + distribution.bucketSize + ); + + const xMax = d3.max(buckets, d => d.x) || 0; + const timeFormatter = getDurationFormatter(xMax); + + return ( + <div> + <EuiTitle size="xs"> + <h5> + {i18n.translate( + 'xpack.apm.transactionDetails.transactionsDurationDistributionChartTitle', + { + defaultMessage: 'Transactions duration distribution' + } + )}{' '} + <EuiIconTip + title={i18n.translate( + 'xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingLabel', + { + defaultMessage: 'Sampling' + } + )} + content={i18n.translate( + 'xpack.apm.transactionDetails.transactionsDurationDistributionChartTooltip.samplingDescription', + { + defaultMessage: + "Each bucket will show a sample transaction. If there's no sample available, it's most likely because of the sampling limit set in the agent configuration." + } + )} + position="top" + /> + </h5> + </EuiTitle> + + <Histogram + buckets={buckets} + bucketSize={distribution.bucketSize} + bucketIndex={bucketIndex} + onClick={(bucket: IChartPoint) => { + if (!isEmpty(bucket.samples)) { + const sample = bucket.samples[0]; + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + transactionId: sample.transactionId, + traceId: sample.traceId + }) + }); + } + }} + formatX={(time: number) => timeFormatter(time).formatted} + formatYShort={formatYShort} + formatYLong={formatYLong} + verticalLineHover={(bucket: IChartPoint) => isEmpty(bucket.samples)} + backgroundHover={(bucket: IChartPoint) => !isEmpty(bucket.samples)} + tooltipHeader={(bucket: IChartPoint) => { + const xFormatted = timeFormatter(bucket.x); + const x0Formatted = timeFormatter(bucket.x0); + return `${x0Formatted.value} - ${xFormatted.value} ${xFormatted.unit}`; + }} + tooltipFooter={(bucket: IChartPoint) => + isEmpty(bucket.samples) && + i18n.translate( + 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.noSampleTooltip', + { + defaultMessage: 'No sample available for this bucket' + } + ) + } + /> + </div> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/ErrorCount.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/MaybeViewTraceLink.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/MaybeViewTraceLink.tsx similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/MaybeViewTraceLink.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/MaybeViewTraceLink.tsx index 4e105957f5f9d..1db8e02e38692 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/MaybeViewTraceLink.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/MaybeViewTraceLink.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiButton, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Transaction as ITransaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction as ITransaction } from '../../../../../typings/es_schemas/ui/transaction'; import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink'; import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/PercentOfParent.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/PercentOfParent.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/PercentOfParent.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/PercentOfParent.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx similarity index 96% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx index 9026dd90ddceb..27e0584c696c1 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/TransactionTabs.tsx @@ -8,7 +8,7 @@ import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Location } from 'history'; import React from 'react'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; import { history } from '../../../../utils/history'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_agent_marks.test.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_agent_marks.test.ts similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_agent_marks.test.ts rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_agent_marks.test.ts index 030729522f35e..ae908b25cc615 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_agent_marks.test.ts +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_agent_marks.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Transaction } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; import { getAgentMarks } from '../get_agent_marks'; describe('getAgentMarks', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_error_marks.test.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_error_marks.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_error_marks.test.ts rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_error_marks.test.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts similarity index 87% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts index 1dcb1598662c9..2bc64e30b4f7e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_agent_marks.ts @@ -5,7 +5,7 @@ */ import { sortBy } from 'lodash'; -import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction'; import { Mark } from '.'; // Extends Mark without adding new properties to it. diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts similarity index 89% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts index a9694efcbcae7..ad54cec5c26a7 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { isEmpty } from 'lodash'; -import { ErrorRaw } from '../../../../../../../../../../plugins/apm/typings/es_schemas/raw/error_raw'; +import { ErrorRaw } from '../../../../../../../typings/es_schemas/raw/error_raw'; import { IWaterfallError, IServiceColors diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/index.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/index.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/index.ts rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/index.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/ServiceLegends.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/ServiceLegends.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/ServiceLegends.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/ServiceLegends.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx similarity index 90% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx index 6e58dbc5b6ea3..bbc457450e475 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/FlyoutTopLevelProperties.tsx @@ -9,8 +9,8 @@ import React from 'react'; import { SERVICE_NAME, TRANSACTION_NAME -} from '../../../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; -import { Transaction } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +} from '../../../../../../../common/elasticsearch_fieldnames'; +import { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction'; import { TransactionDetailLink } from '../../../../../shared/Links/apm/TransactionDetailLink'; import { StickyProperties } from '../../../../../shared/StickyProperties'; import { TransactionOverviewLink } from '../../../../../shared/Links/apm/TransactionOverviewLink'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/ResponsiveFlyout.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx similarity index 96% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx index 6200d5f098ad5..7a08a84bf30ba 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/DatabaseContext.tsx @@ -18,7 +18,7 @@ import SyntaxHighlighter, { // @ts-ignore import { xcode } from 'react-syntax-highlighter/dist/styles'; import styled from 'styled-components'; -import { Span } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; +import { Span } from '../../../../../../../../typings/es_schemas/ui/span'; import { borderRadius, fontFamilyCode, diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx index 438e88df3351d..28564481074fa 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/HttpContext.tsx @@ -17,7 +17,7 @@ import { unit, units } from '../../../../../../../style/variables'; -import { Span } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; +import { Span } from '../../../../../../../../typings/es_schemas/ui/span'; const ContextUrl = styled.div` padding: ${px(units.half)} ${px(unit)}; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx similarity index 86% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx index 621497a0b22e0..d49959c5cbffb 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/StickySpanProperties.tsx @@ -6,14 +6,14 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; -import { Transaction } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; import { SPAN_NAME, TRANSACTION_NAME, SERVICE_NAME -} from '../../../../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; -import { NOT_AVAILABLE_LABEL } from '../../../../../../../../../../../plugins/apm/common/i18n'; -import { Span } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; +} from '../../../../../../../../common/elasticsearch_fieldnames'; +import { NOT_AVAILABLE_LABEL } from '../../../../../../../../common/i18n'; +import { Span } from '../../../../../../../../typings/es_schemas/ui/span'; import { StickyProperties } from '../../../../../../shared/StickyProperties'; import { TransactionOverviewLink } from '../../../../../../shared/Links/apm/TransactionOverviewLink'; import { TransactionDetailLink } from '../../../../../../shared/Links/apm/TransactionDetailLink'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/TruncateHeightSection.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx new file mode 100644 index 0000000000000..1da22516629f2 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SpanFlyout/index.tsx @@ -0,0 +1,239 @@ +/* + * 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 { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiHorizontalRule, + EuiPortal, + EuiSpacer, + EuiTabbedContent, + EuiTitle, + EuiBadge, + EuiToolTip +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { px, units } from '../../../../../../../style/variables'; +import { Summary } from '../../../../../../shared/Summary'; +import { TimestampTooltip } from '../../../../../../shared/TimestampTooltip'; +import { DurationSummaryItem } from '../../../../../../shared/Summary/DurationSummaryItem'; +import { Span } from '../../../../../../../../typings/es_schemas/ui/span'; +import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; +import { DiscoverSpanLink } from '../../../../../../shared/Links/DiscoverLinks/DiscoverSpanLink'; +import { Stacktrace } from '../../../../../../shared/Stacktrace'; +import { ResponsiveFlyout } from '../ResponsiveFlyout'; +import { DatabaseContext } from './DatabaseContext'; +import { StickySpanProperties } from './StickySpanProperties'; +import { HttpInfoSummaryItem } from '../../../../../../shared/Summary/HttpInfoSummaryItem'; +import { SpanMetadata } from '../../../../../../shared/MetadataTable/SpanMetadata'; +import { SyncBadge } from '../SyncBadge'; + +function formatType(type: string) { + switch (type) { + case 'db': + return 'DB'; + case 'hard-navigation': + return i18n.translate( + 'xpack.apm.transactionDetails.spanFlyout.spanType.navigationTimingLabel', + { + defaultMessage: 'Navigation timing' + } + ); + default: + return type; + } +} + +function formatSubtype(subtype: string | undefined) { + switch (subtype) { + case 'mysql': + return 'MySQL'; + default: + return subtype; + } +} + +function getSpanTypes(span: Span) { + const { type, subtype, action } = span.span; + + return { + spanType: formatType(type), + spanSubtype: formatSubtype(subtype), + spanAction: action + }; +} + +const SpanBadge = (styled(EuiBadge)` + display: inline-block; + margin-right: ${px(units.quarter)}; +` as unknown) as typeof EuiBadge; + +const HttpInfoContainer = styled('div')` + margin-right: ${px(units.quarter)}; +`; + +interface Props { + span?: Span; + parentTransaction?: Transaction; + totalDuration?: number; + onClose: () => void; +} + +export function SpanFlyout({ + span, + parentTransaction, + totalDuration, + onClose +}: Props) { + if (!span) { + return null; + } + + const stackframes = span.span.stacktrace; + const codeLanguage = parentTransaction?.service.language?.name; + const dbContext = span.span.db; + const httpContext = span.span.http; + const spanTypes = getSpanTypes(span); + const spanHttpStatusCode = httpContext?.response?.status_code; + const spanHttpUrl = httpContext?.url?.original; + const spanHttpMethod = httpContext?.method; + + return ( + <EuiPortal> + <ResponsiveFlyout onClose={onClose} size="m" ownFocus={true}> + <EuiFlyoutHeader hasBorder> + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiTitle> + <h2> + {i18n.translate( + 'xpack.apm.transactionDetails.spanFlyout.spanDetailsTitle', + { + defaultMessage: 'Span details' + } + )} + </h2> + </EuiTitle> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <DiscoverSpanLink span={span}> + <EuiButtonEmpty iconType="discoverApp"> + {i18n.translate( + 'xpack.apm.transactionDetails.spanFlyout.viewSpanInDiscoverButtonLabel', + { + defaultMessage: 'View span in Discover' + } + )} + </EuiButtonEmpty> + </DiscoverSpanLink> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <StickySpanProperties span={span} transaction={parentTransaction} /> + <EuiSpacer size="m" /> + <Summary + items={[ + <TimestampTooltip time={span.timestamp.us / 1000} />, + <DurationSummaryItem + duration={span.span.duration.us} + totalDuration={totalDuration} + parentType="transaction" + />, + <> + {spanHttpUrl && ( + <HttpInfoContainer> + <HttpInfoSummaryItem + method={spanHttpMethod} + url={spanHttpUrl} + status={spanHttpStatusCode} + /> + </HttpInfoContainer> + )} + <EuiToolTip + content={i18n.translate( + 'xpack.apm.transactionDetails.spanFlyout.spanType', + { defaultMessage: 'Type' } + )} + > + <SpanBadge color="hollow">{spanTypes.spanType}</SpanBadge> + </EuiToolTip> + {spanTypes.spanSubtype && ( + <EuiToolTip + content={i18n.translate( + 'xpack.apm.transactionDetails.spanFlyout.spanSubtype', + { defaultMessage: 'Subtype' } + )} + > + <SpanBadge color="hollow"> + {spanTypes.spanSubtype} + </SpanBadge> + </EuiToolTip> + )} + {spanTypes.spanAction && ( + <EuiToolTip + content={i18n.translate( + 'xpack.apm.transactionDetails.spanFlyout.spanAction', + { defaultMessage: 'Action' } + )} + > + <SpanBadge color="hollow">{spanTypes.spanAction}</SpanBadge> + </EuiToolTip> + )} + <SyncBadge sync={span.span.sync} /> + </> + ]} + /> + <EuiHorizontalRule /> + <DatabaseContext dbContext={dbContext} /> + <EuiTabbedContent + tabs={[ + { + id: 'stack-trace', + name: i18n.translate( + 'xpack.apm.transactionDetails.spanFlyout.stackTraceTabLabel', + { + defaultMessage: 'Stack Trace' + } + ), + content: ( + <Fragment> + <EuiSpacer size="l" /> + <Stacktrace + stackframes={stackframes} + codeLanguage={codeLanguage} + /> + </Fragment> + ) + }, + { + id: 'metadata', + name: i18n.translate( + 'xpack.apm.propertiesTable.tabs.metadataLabel', + { + defaultMessage: 'Metadata' + } + ), + content: ( + <Fragment> + <EuiSpacer size="m" /> + <SpanMetadata span={span} /> + </Fragment> + ) + } + ]} + /> + </EuiFlyoutBody> + </ResponsiveFlyout> + </EuiPortal> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx index 85cf0b642530f..87ecb96f74735 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/DroppedSpansWarning.tsx @@ -7,7 +7,7 @@ import { EuiCallOut, EuiHorizontalRule } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { Transaction } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; import { ElasticDocsLink } from '../../../../../../shared/Links/ElasticDocsLink'; export function DroppedSpansWarning({ diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx new file mode 100644 index 0000000000000..5fb679818f0a7 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/TransactionFlyout/index.tsx @@ -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 { + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiPortal, + EuiSpacer, + EuiTitle, + EuiHorizontalRule +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; +import { TransactionActionMenu } from '../../../../../../shared/TransactionActionMenu/TransactionActionMenu'; +import { TransactionSummary } from '../../../../../../shared/Summary/TransactionSummary'; +import { FlyoutTopLevelProperties } from '../FlyoutTopLevelProperties'; +import { ResponsiveFlyout } from '../ResponsiveFlyout'; +import { TransactionMetadata } from '../../../../../../shared/MetadataTable/TransactionMetadata'; +import { DroppedSpansWarning } from './DroppedSpansWarning'; + +interface Props { + onClose: () => void; + transaction?: Transaction; + errorCount?: number; + rootTransactionDuration?: number; +} + +function TransactionPropertiesTable({ + transaction +}: { + transaction: Transaction; +}) { + return ( + <div> + <EuiTitle size="s"> + <h4>Metadata</h4> + </EuiTitle> + <TransactionMetadata transaction={transaction} /> + </div> + ); +} + +export function TransactionFlyout({ + transaction: transactionDoc, + onClose, + errorCount = 0, + rootTransactionDuration +}: Props) { + if (!transactionDoc) { + return null; + } + + return ( + <EuiPortal> + <ResponsiveFlyout onClose={onClose} ownFocus={true} maxWidth={false}> + <EuiFlyoutHeader hasBorder> + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiTitle> + <h4> + {i18n.translate( + 'xpack.apm.transactionDetails.transFlyout.transactionDetailsTitle', + { + defaultMessage: 'Transaction details' + } + )} + </h4> + </EuiTitle> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <TransactionActionMenu transaction={transactionDoc} /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <FlyoutTopLevelProperties transaction={transactionDoc} /> + <EuiSpacer size="m" /> + <TransactionSummary + transaction={transactionDoc} + totalDuration={rootTransactionDuration} + errorCount={errorCount} + /> + <EuiHorizontalRule margin="m" /> + <DroppedSpansWarning transactionDoc={transactionDoc} /> + <TransactionPropertiesTable transaction={transactionDoc} /> + </EuiFlyoutBody> + </ResponsiveFlyout> + </EuiPortal> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallFlyout.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx similarity index 96% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx index 5c6e0cc5ce435..d8edcce46c2d7 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/WaterfallItem.tsx @@ -10,13 +10,13 @@ import styled from 'styled-components'; import { EuiIcon, EuiText, EuiTitle, EuiToolTip } from '@elastic/eui'; import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; -import { isRumAgentName } from '../../../../../../../../../../plugins/apm/common/agent_name'; +import { isRumAgentName } from '../../../../../../../common/agent_name'; import { px, unit, units } from '../../../../../../style/variables'; import { asDuration } from '../../../../../../utils/formatters'; import { ErrorCount } from '../../ErrorCount'; import { IWaterfallItem } from './waterfall_helpers/waterfall_helpers'; import { ErrorOverviewLink } from '../../../../../shared/Links/apm/ErrorOverviewLink'; -import { TRACE_ID } from '../../../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +import { TRACE_ID } from '../../../../../../../common/elasticsearch_fieldnames'; import { SyncBadge } from './SyncBadge'; import { Margins } from '../../../../../shared/charts/Timeline'; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/spans.json b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/spans.json similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/spans.json rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/spans.json diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/transaction.json b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/transaction.json similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/transaction.json rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/mock_responses/transaction.json diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts similarity index 98% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts index e0a01e9422c85..75304932ed2ba 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts @@ -5,8 +5,8 @@ */ import { groupBy } from 'lodash'; -import { Span } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; -import { Transaction } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Span } from '../../../../../../../../typings/es_schemas/ui/span'; +import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; import { getClockSkew, getOrderedWaterfallItems, @@ -15,7 +15,7 @@ import { IWaterfallTransaction, IWaterfallError } from './waterfall_helpers'; -import { APMError } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; +import { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error'; describe('waterfall_helpers', () => { describe('getWaterfall', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts index 73193cc7c9dbb..8ddce66f0b853 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts @@ -16,10 +16,10 @@ import { zipObject } from 'lodash'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TraceAPIResponse } from '../../../../../../../../../../../plugins/apm/server/lib/traces/get_trace'; -import { APMError } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; -import { Span } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; -import { Transaction } from '../../../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { TraceAPIResponse } from '../../../../../../../../server/lib/traces/get_trace'; +import { APMError } from '../../../../../../../../typings/es_schemas/ui/apm_error'; +import { Span } from '../../../../../../../../typings/es_schemas/ui/span'; +import { Transaction } from '../../../../../../../../typings/es_schemas/ui/transaction'; interface IWaterfallGroup { [key: string]: IWaterfallItem[]; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx index f681f4dfc675a..87710fb9b8d96 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TraceAPIResponse } from '../../../../../../../../../plugins/apm/server/lib/traces/get_trace'; +import { TraceAPIResponse } from '../../../../../../server/lib/traces/get_trace'; import { WaterfallContainer } from './index'; import { location, diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/waterfallContainer.stories.data.ts b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/waterfallContainer.stories.data.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/waterfallContainer.stories.data.ts rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/waterfallContainer.stories.data.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/__tests__/ErrorCount.test.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/__tests__/ErrorCount.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/__tests__/ErrorCount.test.tsx rename to x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/__tests__/ErrorCount.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx new file mode 100644 index 0000000000000..056e9cdb75148 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/index.tsx @@ -0,0 +1,138 @@ +/* + * 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 { + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiPagination, + EuiPanel, + EuiSpacer, + EuiTitle +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Location } from 'history'; +import React, { useEffect, useState } from 'react'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IBucket } from '../../../../../server/lib/transactions/distribution/get_buckets/transform'; +import { IUrlParams } from '../../../../context/UrlParamsContext/types'; +import { history } from '../../../../utils/history'; +import { fromQuery, toQuery } from '../../../shared/Links/url_helpers'; +import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; +import { TransactionSummary } from '../../../shared/Summary/TransactionSummary'; +import { TransactionActionMenu } from '../../../shared/TransactionActionMenu/TransactionActionMenu'; +import { MaybeViewTraceLink } from './MaybeViewTraceLink'; +import { TransactionTabs } from './TransactionTabs'; +import { IWaterfall } from './WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers'; + +interface Props { + urlParams: IUrlParams; + location: Location; + waterfall: IWaterfall; + exceedsMax: boolean; + isLoading: boolean; + traceSamples: IBucket['samples']; +} + +export const WaterfallWithSummmary: React.FC<Props> = ({ + urlParams, + location, + waterfall, + exceedsMax, + isLoading, + traceSamples +}) => { + const [sampleActivePage, setSampleActivePage] = useState(0); + + useEffect(() => { + setSampleActivePage(0); + }, [traceSamples]); + + const goToSample = (index: number) => { + setSampleActivePage(index); + const sample = traceSamples[index]; + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + transactionId: sample.transactionId, + traceId: sample.traceId + }) + }); + }; + + const { entryTransaction } = waterfall; + if (!entryTransaction) { + const content = isLoading ? ( + <LoadingStatePrompt /> + ) : ( + <EuiEmptyPrompt + title={ + <div> + {i18n.translate('xpack.apm.transactionDetails.traceNotFound', { + defaultMessage: 'The selected trace cannot be found' + })} + </div> + } + titleSize="s" + /> + ); + + return <EuiPanel paddingSize="m">{content}</EuiPanel>; + } + + return ( + <EuiPanel paddingSize="m"> + <EuiFlexGroup> + <EuiFlexItem style={{ flexDirection: 'row', alignItems: 'center' }}> + <EuiTitle size="xs"> + <h5> + {i18n.translate('xpack.apm.transactionDetails.traceSampleTitle', { + defaultMessage: 'Trace sample' + })} + </h5> + </EuiTitle> + {traceSamples && ( + <EuiPagination + pageCount={traceSamples.length} + activePage={sampleActivePage} + onPageClick={goToSample} + compressed + /> + )} + </EuiFlexItem> + <EuiFlexItem> + <EuiFlexGroup justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <TransactionActionMenu transaction={entryTransaction} /> + </EuiFlexItem> + <MaybeViewTraceLink + transaction={entryTransaction} + waterfall={waterfall} + /> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + + <EuiSpacer size="s" /> + + <TransactionSummary + errorCount={waterfall.errorsCount} + totalDuration={waterfall.rootTransaction?.transaction.duration.us} + transaction={entryTransaction} + /> + <EuiSpacer size="s" /> + + <TransactionTabs + transaction={entryTransaction} + location={location} + urlParams={urlParams} + waterfall={waterfall} + exceedsMax={exceedsMax} + /> + </EuiPanel> + ); +}; diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx new file mode 100644 index 0000000000000..2544dc2a1a77c --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx @@ -0,0 +1,127 @@ +/* + * 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 { + EuiPanel, + EuiSpacer, + EuiTitle, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem +} from '@elastic/eui'; +import _ from 'lodash'; +import React, { useMemo } from 'react'; +import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; +import { useTransactionDistribution } from '../../../hooks/useTransactionDistribution'; +import { useWaterfall } from '../../../hooks/useWaterfall'; +import { TransactionCharts } from '../../shared/charts/TransactionCharts'; +import { ApmHeader } from '../../shared/ApmHeader'; +import { TransactionDistribution } from './Distribution'; +import { WaterfallWithSummmary } from './WaterfallWithSummmary'; +import { useLocation } from '../../../hooks/useLocation'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; +import { TransactionBreakdown } from '../../shared/TransactionBreakdown'; +import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; +import { useTrackPageview } from '../../../../../observability/public'; +import { PROJECTION } from '../../../../common/projections/typings'; +import { LocalUIFilters } from '../../shared/LocalUIFilters'; +import { HeightRetainer } from '../../shared/HeightRetainer'; + +export function TransactionDetails() { + const location = useLocation(); + const { urlParams } = useUrlParams(); + const { + data: distributionData, + status: distributionStatus + } = useTransactionDistribution(urlParams); + + const { data: transactionChartsData } = useTransactionCharts(); + const { waterfall, exceedsMax, status: waterfallStatus } = useWaterfall( + urlParams + ); + const { transactionName, transactionType, serviceName } = urlParams; + + useTrackPageview({ app: 'apm', path: 'transaction_details' }); + useTrackPageview({ app: 'apm', path: 'transaction_details', delay: 15000 }); + + const localUIFiltersConfig = useMemo(() => { + const config: React.ComponentProps<typeof LocalUIFilters> = { + filterNames: ['transactionResult', 'serviceVersion'], + projection: PROJECTION.TRANSACTIONS, + params: { + transactionName, + transactionType, + serviceName + } + }; + return config; + }, [transactionName, transactionType, serviceName]); + + const bucketIndex = distributionData.buckets.findIndex(bucket => + bucket.samples.some( + sample => + sample.transactionId === urlParams.transactionId && + sample.traceId === urlParams.traceId + ) + ); + + const traceSamples = distributionData.buckets[bucketIndex]?.samples; + + return ( + <div> + <ApmHeader> + <EuiTitle size="l"> + <h1>{transactionName}</h1> + </EuiTitle> + </ApmHeader> + + <EuiFlexGroup> + <EuiFlexItem grow={1}> + <LocalUIFilters {...localUIFiltersConfig} /> + </EuiFlexItem> + <EuiFlexItem grow={7}> + <ChartsSyncContextProvider> + <TransactionBreakdown /> + + <EuiSpacer size="s" /> + + <TransactionCharts + hasMLJob={false} + charts={transactionChartsData} + urlParams={urlParams} + location={location} + /> + </ChartsSyncContextProvider> + + <EuiHorizontalRule size="full" margin="l" /> + + <EuiPanel> + <TransactionDistribution + distribution={distributionData} + isLoading={distributionStatus === FETCH_STATUS.LOADING} + urlParams={urlParams} + bucketIndex={bucketIndex} + /> + </EuiPanel> + + <EuiSpacer size="s" /> + + <HeightRetainer> + <WaterfallWithSummmary + location={location} + urlParams={urlParams} + waterfall={waterfall} + isLoading={waterfallStatus === FETCH_STATUS.LOADING} + exceedsMax={exceedsMax} + traceSamples={traceSamples} + /> + </HeightRetainer> + </EuiFlexItem> + </EuiFlexGroup> + </div> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx new file mode 100644 index 0000000000000..e3b33f11d0805 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx @@ -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 { EuiIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ITransactionGroup } from '../../../../../server/lib/transaction_groups/transform'; +import { fontFamilyCode, truncate } from '../../../../style/variables'; +import { asDecimal, convertTo } from '../../../../utils/formatters'; +import { ImpactBar } from '../../../shared/ImpactBar'; +import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable'; +import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; +import { EmptyMessage } from '../../../shared/EmptyMessage'; +import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink'; + +const TransactionNameLink = styled(TransactionDetailLink)` + ${truncate('100%')}; + font-family: ${fontFamilyCode}; +`; + +interface Props { + items: ITransactionGroup[]; + isLoading: boolean; +} + +const toMilliseconds = (time: number) => + convertTo({ + unit: 'milliseconds', + microseconds: time + }).formatted; + +export function TransactionList({ items, isLoading }: Props) { + const columns: Array<ITableColumn<ITransactionGroup>> = useMemo( + () => [ + { + field: 'name', + name: i18n.translate('xpack.apm.transactionsTable.nameColumnLabel', { + defaultMessage: 'Name' + }), + width: '50%', + sortable: true, + render: (transactionName: string, { sample }: ITransactionGroup) => { + return ( + <EuiToolTip + id="transaction-name-link-tooltip" + content={transactionName || NOT_AVAILABLE_LABEL} + > + <TransactionNameLink + serviceName={sample.service.name} + transactionId={sample.transaction.id} + traceId={sample.trace.id} + transactionName={sample.transaction.name} + transactionType={sample.transaction.type} + > + {transactionName || NOT_AVAILABLE_LABEL} + </TransactionNameLink> + </EuiToolTip> + ); + } + }, + { + field: 'averageResponseTime', + name: i18n.translate( + 'xpack.apm.transactionsTable.avgDurationColumnLabel', + { + defaultMessage: 'Avg. duration' + } + ), + sortable: true, + dataType: 'number', + render: (time: number) => toMilliseconds(time) + }, + { + field: 'p95', + name: i18n.translate( + 'xpack.apm.transactionsTable.95thPercentileColumnLabel', + { + defaultMessage: '95th percentile' + } + ), + sortable: true, + dataType: 'number', + render: (time: number) => toMilliseconds(time) + }, + { + field: 'transactionsPerMinute', + name: i18n.translate( + 'xpack.apm.transactionsTable.transactionsPerMinuteColumnLabel', + { + defaultMessage: 'Trans. per minute' + } + ), + sortable: true, + dataType: 'number', + render: (value: number) => + `${asDecimal(value)} ${i18n.translate( + 'xpack.apm.transactionsTable.transactionsPerMinuteUnitLabel', + { + defaultMessage: 'tpm' + } + )}` + }, + { + field: 'impact', + name: ( + <EuiToolTip + content={i18n.translate( + 'xpack.apm.transactionsTable.impactColumnDescription', + { + defaultMessage: + "The most used and slowest endpoints in your service. It's calculated by taking the relative average duration times the number of transactions per minute." + } + )} + > + <> + {i18n.translate('xpack.apm.transactionsTable.impactColumnLabel', { + defaultMessage: 'Impact' + })}{' '} + <EuiIcon + size="s" + color="subdued" + type="questionInCircle" + className="eui-alignTop" + /> + </> + </EuiToolTip> + ), + sortable: true, + dataType: 'number', + render: (value: number) => <ImpactBar value={value} /> + } + ], + [] + ); + + const noItemsMessage = ( + <EmptyMessage + heading={i18n.translate('xpack.apm.transactionsTable.notFoundLabel', { + defaultMessage: 'No transactions were found.' + })} + /> + ); + + return ( + <ManagedTable + noItemsMessage={isLoading ? <LoadingStatePrompt /> : noItemsMessage} + columns={columns} + items={items} + initialSortField="impact" + initialSortDirection="desc" + initialPageSize={25} + /> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx rename to x-pack/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx new file mode 100644 index 0000000000000..60aac3fcdfeef --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -0,0 +1,162 @@ +/* + * 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 { + EuiPanel, + EuiSpacer, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule +} from '@elastic/eui'; +import { Location } from 'history'; +import { first } from 'lodash'; +import React, { useMemo } from 'react'; +import { useTransactionList } from '../../../hooks/useTransactionList'; +import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; +import { IUrlParams } from '../../../context/UrlParamsContext/types'; +import { TransactionCharts } from '../../shared/charts/TransactionCharts'; +import { TransactionBreakdown } from '../../shared/TransactionBreakdown'; +import { TransactionList } from './List'; +import { useRedirect } from './useRedirect'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { getHasMLJob } from '../../../services/rest/ml'; +import { history } from '../../../utils/history'; +import { useLocation } from '../../../hooks/useLocation'; +import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; +import { useTrackPageview } from '../../../../../observability/public'; +import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; +import { LocalUIFilters } from '../../shared/LocalUIFilters'; +import { PROJECTION } from '../../../../common/projections/typings'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; +import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; + +function getRedirectLocation({ + urlParams, + location, + serviceTransactionTypes +}: { + location: Location; + urlParams: IUrlParams; + serviceTransactionTypes: string[]; +}): Location | undefined { + const { transactionType } = urlParams; + const firstTransactionType = first(serviceTransactionTypes); + + if (!transactionType && firstTransactionType) { + return { + ...location, + search: fromQuery({ + ...toQuery(location.search), + transactionType: firstTransactionType + }) + }; + } +} + +export function TransactionOverview() { + const location = useLocation(); + const { urlParams } = useUrlParams(); + const { serviceName, transactionType } = urlParams; + + // TODO: fetching of transaction types should perhaps be lifted since it is needed in several places. Context? + const serviceTransactionTypes = useServiceTransactionTypes(urlParams); + + // redirect to first transaction type + useRedirect( + history, + getRedirectLocation({ + urlParams, + location, + serviceTransactionTypes + }) + ); + + const { data: transactionCharts } = useTransactionCharts(); + + useTrackPageview({ app: 'apm', path: 'transaction_overview' }); + useTrackPageview({ app: 'apm', path: 'transaction_overview', delay: 15000 }); + const { + data: transactionListData, + status: transactionListStatus + } = useTransactionList(urlParams); + + const { http } = useApmPluginContext().core; + + const { data: hasMLJob = false } = useFetcher(() => { + if (serviceName && transactionType) { + return getHasMLJob({ serviceName, transactionType, http }); + } + }, [http, serviceName, transactionType]); + + const localFiltersConfig: React.ComponentProps<typeof LocalUIFilters> = useMemo( + () => ({ + filterNames: [ + 'transactionResult', + 'host', + 'containerId', + 'podName', + 'serviceVersion' + ], + params: { + serviceName, + transactionType + }, + projection: PROJECTION.TRANSACTION_GROUPS + }), + [serviceName, transactionType] + ); + + // TODO: improve urlParams typings. + // `serviceName` or `transactionType` will never be undefined here, and this check should not be needed + if (!serviceName || !transactionType) { + return null; + } + + return ( + <> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem grow={1}> + <LocalUIFilters {...localFiltersConfig}> + <TransactionTypeFilter transactionTypes={serviceTransactionTypes} /> + <EuiSpacer size="xl" /> + <EuiHorizontalRule margin="none" /> + </LocalUIFilters> + </EuiFlexItem> + <EuiFlexItem grow={7}> + <ChartsSyncContextProvider> + <TransactionBreakdown initialIsOpen={true} /> + + <EuiSpacer size="s" /> + + <TransactionCharts + hasMLJob={hasMLJob} + charts={transactionCharts} + location={location} + urlParams={urlParams} + /> + </ChartsSyncContextProvider> + + <EuiSpacer size="s" /> + + <EuiPanel> + <EuiTitle size="xs"> + <h3>Transactions</h3> + </EuiTitle> + <EuiSpacer size="s" /> + <TransactionList + isLoading={transactionListStatus === 'loading'} + items={transactionListData} + /> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/useRedirect.ts b/x-pack/plugins/apm/public/components/app/TransactionOverview/useRedirect.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/useRedirect.ts rename to x-pack/plugins/apm/public/components/app/TransactionOverview/useRedirect.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ApmHeader/index.tsx rename to x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx rename to x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/DatePicker/index.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/DatePicker/index.tsx rename to x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/EmptyMessage.tsx b/x-pack/plugins/apm/public/components/shared/EmptyMessage.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/EmptyMessage.tsx rename to x-pack/plugins/apm/public/components/shared/EmptyMessage.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/EnvironmentBadge/index.tsx b/x-pack/plugins/apm/public/components/shared/EnvironmentBadge/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/EnvironmentBadge/index.tsx rename to x-pack/plugins/apm/public/components/shared/EnvironmentBadge/index.tsx diff --git a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx new file mode 100644 index 0000000000000..d17b3b7689b19 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx @@ -0,0 +1,112 @@ +/* + * 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 { EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { useLocation } from '../../../hooks/useLocation'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { history } from '../../../utils/history'; +import { fromQuery, toQuery } from '../Links/url_helpers'; +import { + ENVIRONMENT_ALL, + ENVIRONMENT_NOT_DEFINED +} from '../../../../common/environment_filter_values'; + +function updateEnvironmentUrl( + location: ReturnType<typeof useLocation>, + environment?: string +) { + const nextEnvironmentQueryParam = + environment !== ENVIRONMENT_ALL ? environment : undefined; + history.push({ + ...location, + search: fromQuery({ + ...toQuery(location.search), + environment: nextEnvironmentQueryParam + }) + }); +} + +const ALL_OPTION = { + value: ENVIRONMENT_ALL, + text: i18n.translate('xpack.apm.filter.environment.allLabel', { + defaultMessage: 'All' + }) +}; + +const NOT_DEFINED_OPTION = { + value: ENVIRONMENT_NOT_DEFINED, + text: i18n.translate('xpack.apm.filter.environment.notDefinedLabel', { + defaultMessage: 'Not defined' + }) +}; + +const SEPARATOR_OPTION = { + text: `- ${i18n.translate( + 'xpack.apm.filter.environment.selectEnvironmentLabel', + { defaultMessage: 'Select environment' } + )} -`, + disabled: true +}; + +function getOptions(environments: string[]) { + const environmentOptions = environments + .filter(env => env !== ENVIRONMENT_NOT_DEFINED) + .map(environment => ({ + value: environment, + text: environment + })); + + return [ + ALL_OPTION, + ...(environments.includes(ENVIRONMENT_NOT_DEFINED) + ? [NOT_DEFINED_OPTION] + : []), + ...(environmentOptions.length > 0 ? [SEPARATOR_OPTION] : []), + ...environmentOptions + ]; +} + +export const EnvironmentFilter: React.FC = () => { + const location = useLocation(); + const { urlParams, uiFilters } = useUrlParams(); + const { start, end, serviceName } = urlParams; + + const { environment } = uiFilters; + const { data: environments = [], status = 'loading' } = useFetcher( + callApmApi => { + if (start && end) { + return callApmApi({ + pathname: '/api/apm/ui_filters/environments', + params: { + query: { + start, + end, + serviceName + } + } + }); + } + }, + [start, end, serviceName] + ); + + return ( + <EuiSelect + prepend={i18n.translate('xpack.apm.filter.environment.label', { + defaultMessage: 'environment' + })} + options={getOptions(environments)} + value={environment || ENVIRONMENT_ALL} + onChange={event => { + updateEnvironmentUrl(location, event.target.value); + }} + isLoading={status === 'loading'} + /> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx rename to x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx diff --git a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx new file mode 100644 index 0000000000000..658def7ddbb57 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFieldNumber } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isFinite } from 'lodash'; +import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; +import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +export interface ErrorRateAlertTriggerParams { + windowSize: number; + windowUnit: string; + threshold: number; +} + +interface Props { + alertParams: ErrorRateAlertTriggerParams; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function ErrorRateAlertTrigger(props: Props) { + const { setAlertParams, setAlertProperty, alertParams } = props; + + const defaults = { + threshold: 25, + windowSize: 1, + windowUnit: 'm' + }; + + const params = { + ...defaults, + ...alertParams + }; + + const threshold = isFinite(params.threshold) ? params.threshold : ''; + + const fields = [ + <PopoverExpression + title={i18n.translate('xpack.apm.errorRateAlertTrigger.isAbove', { + defaultMessage: 'is above' + })} + value={threshold.toString()} + > + <EuiFieldNumber + value={threshold} + step={0} + onChange={e => + setAlertParams('threshold', parseInt(e.target.value, 10)) + } + compressed + append={i18n.translate('xpack.apm.errorRateAlertTrigger.errors', { + defaultMessage: 'errors' + })} + /> + </PopoverExpression>, + <ForLastExpression + onChangeWindowSize={windowSize => + setAlertParams('windowSize', windowSize || '') + } + onChangeWindowUnit={windowUnit => + setAlertParams('windowUnit', windowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + <ServiceAlertTrigger + alertTypeName={ALERT_TYPES_CONFIG['apm.error_rate'].name} + defaults={defaults} + fields={fields} + setAlertParams={setAlertParams} + setAlertProperty={setAlertProperty} + /> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorStatePrompt.tsx b/x-pack/plugins/apm/public/components/shared/ErrorStatePrompt.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ErrorStatePrompt.tsx rename to x-pack/plugins/apm/public/components/shared/ErrorStatePrompt.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/EuiTabLink.tsx b/x-pack/plugins/apm/public/components/shared/EuiTabLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/EuiTabLink.tsx rename to x-pack/plugins/apm/public/components/shared/EuiTabLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/HeightRetainer/index.tsx b/x-pack/plugins/apm/public/components/shared/HeightRetainer/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/HeightRetainer/index.tsx rename to x-pack/plugins/apm/public/components/shared/HeightRetainer/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ImpactBar/__test__/ImpactBar.test.js b/x-pack/plugins/apm/public/components/shared/ImpactBar/__test__/ImpactBar.test.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ImpactBar/__test__/ImpactBar.test.js rename to x-pack/plugins/apm/public/components/shared/ImpactBar/__test__/ImpactBar.test.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ImpactBar/__test__/__snapshots__/ImpactBar.test.js.snap b/x-pack/plugins/apm/public/components/shared/ImpactBar/__test__/__snapshots__/ImpactBar.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ImpactBar/__test__/__snapshots__/ImpactBar.test.js.snap rename to x-pack/plugins/apm/public/components/shared/ImpactBar/__test__/__snapshots__/ImpactBar.test.js.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ImpactBar/index.tsx b/x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ImpactBar/index.tsx rename to x-pack/plugins/apm/public/components/shared/ImpactBar/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KeyValueTable/FormattedValue.tsx b/x-pack/plugins/apm/public/components/shared/KeyValueTable/FormattedValue.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/KeyValueTable/FormattedValue.tsx rename to x-pack/plugins/apm/public/components/shared/KeyValueTable/FormattedValue.tsx index 4de07f75ff84f..d33960fe5196b 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/KeyValueTable/FormattedValue.tsx +++ b/x-pack/plugins/apm/public/components/shared/KeyValueTable/FormattedValue.tsx @@ -8,7 +8,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { isBoolean, isNumber, isObject } from 'lodash'; import React from 'react'; import styled from 'styled-components'; -import { NOT_AVAILABLE_LABEL } from '../../../../../../../plugins/apm/common/i18n'; +import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; const EmptyValue = styled.span` color: ${theme.euiColorMediumShade}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KeyValueTable/__test__/KeyValueTable.test.tsx b/x-pack/plugins/apm/public/components/shared/KeyValueTable/__test__/KeyValueTable.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/KeyValueTable/__test__/KeyValueTable.test.tsx rename to x-pack/plugins/apm/public/components/shared/KeyValueTable/__test__/KeyValueTable.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KeyValueTable/index.tsx b/x-pack/plugins/apm/public/components/shared/KeyValueTable/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/KeyValueTable/index.tsx rename to x-pack/plugins/apm/public/components/shared/KeyValueTable/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/Typeahead/ClickOutside.js b/x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/ClickOutside.js similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/shared/KueryBar/Typeahead/ClickOutside.js rename to x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/ClickOutside.js index 5ad256efe0945..93a95c844a975 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/Typeahead/ClickOutside.js +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/ClickOutside.js @@ -27,6 +27,7 @@ export default class ClickOutside extends Component { }; render() { + // eslint-disable-next-line no-unused-vars const { onClickOutside, ...restProps } = this.props; return ( <div ref={this.setNodeRef} {...restProps}> diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/Typeahead/Suggestion.js b/x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/Suggestion.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/KueryBar/Typeahead/Suggestion.js rename to x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/Suggestion.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/Typeahead/Suggestions.js b/x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/Suggestions.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/KueryBar/Typeahead/Suggestions.js rename to x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/Suggestions.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/Typeahead/index.js b/x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/index.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/KueryBar/Typeahead/index.js rename to x-pack/plugins/apm/public/components/shared/KueryBar/Typeahead/index.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/get_bool_filter.ts b/x-pack/plugins/apm/public/components/shared/KueryBar/get_bool_filter.ts similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/shared/KueryBar/get_bool_filter.ts rename to x-pack/plugins/apm/public/components/shared/KueryBar/get_bool_filter.ts index 19a9ae9538ad6..f4628524cced5 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/get_bool_filter.ts +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/get_bool_filter.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ESFilter } from '../../../../../../../plugins/apm/typings/elasticsearch'; +import { ESFilter } from '../../../../typings/elasticsearch'; import { TRANSACTION_TYPE, ERROR_GROUP_ID, PROCESSOR_EVENT, TRANSACTION_NAME, SERVICE_NAME -} from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +} from '../../../../common/elasticsearch_fieldnames'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; export function getBoolFilter(urlParams: IUrlParams) { diff --git a/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx new file mode 100644 index 0000000000000..2622d08d4779d --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/KueryBar/index.tsx @@ -0,0 +1,156 @@ +/* + * 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 { uniqueId, startsWith } from 'lodash'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { fromQuery, toQuery } from '../Links/url_helpers'; +// @ts-ignore +import { Typeahead } from './Typeahead'; +import { getBoolFilter } from './get_bool_filter'; +import { useLocation } from '../../../hooks/useLocation'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { history } from '../../../utils/history'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { useDynamicIndexPattern } from '../../../hooks/useDynamicIndexPattern'; +import { + QuerySuggestion, + esKuery, + IIndexPattern +} from '../../../../../../../src/plugins/data/public'; + +const Container = styled.div` + margin-bottom: 10px; +`; + +interface State { + suggestions: QuerySuggestion[]; + isLoadingSuggestions: boolean; +} + +function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) { + const ast = esKuery.fromKueryExpression(kuery); + return esKuery.toElasticsearchQuery(ast, indexPattern); +} + +export function KueryBar() { + const [state, setState] = useState<State>({ + suggestions: [], + isLoadingSuggestions: false + }); + const { urlParams } = useUrlParams(); + const location = useLocation(); + const { data } = useApmPluginContext().plugins; + + let currentRequestCheck; + + const { processorEvent } = urlParams; + + const examples = { + transaction: 'transaction.duration.us > 300000', + error: 'http.response.status_code >= 400', + metric: 'process.pid = "1234"', + defaults: + 'transaction.duration.us > 300000 AND http.response.status_code >= 400' + }; + + const example = examples[processorEvent || 'defaults']; + + const { indexPattern } = useDynamicIndexPattern(processorEvent); + + const placeholder = i18n.translate('xpack.apm.kueryBar.placeholder', { + defaultMessage: `Search {event, select, + transaction {transactions} + metric {metrics} + error {errors} + other {transactions, errors and metrics} + } (E.g. {queryExample})`, + values: { + queryExample: example, + event: processorEvent + } + }); + + // The bar should be disabled when viewing the service map + const disabled = /\/service-map$/.test(location.pathname); + const disabledPlaceholder = i18n.translate( + 'xpack.apm.kueryBar.disabledPlaceholder', + { defaultMessage: 'Search is not available for service map' } + ); + + async function onChange(inputValue: string, selectionStart: number) { + if (indexPattern == null) { + return; + } + + setState({ ...state, suggestions: [], isLoadingSuggestions: true }); + + const currentRequest = uniqueId(); + currentRequestCheck = currentRequest; + + try { + const suggestions = ( + (await data.autocomplete.getQuerySuggestions({ + language: 'kuery', + indexPatterns: [indexPattern], + boolFilter: getBoolFilter(urlParams), + query: inputValue, + selectionStart, + selectionEnd: selectionStart + })) || [] + ) + .filter(suggestion => !startsWith(suggestion.text, 'span.')) + .slice(0, 15); + + if (currentRequest !== currentRequestCheck) { + return; + } + + setState({ ...state, suggestions, isLoadingSuggestions: false }); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error while fetching suggestions', e); + } + } + + function onSubmit(inputValue: string) { + if (indexPattern == null) { + return; + } + + try { + const res = convertKueryToEsQuery(inputValue, indexPattern); + if (!res) { + return; + } + + history.push({ + ...location, + search: fromQuery({ + ...toQuery(location.search), + kuery: encodeURIComponent(inputValue.trim()) + }) + }); + } catch (e) { + console.log('Invalid kuery syntax'); // eslint-disable-line no-console + } + } + + return ( + <Container> + <Typeahead + disabled={disabled} + isLoading={state.isLoadingSuggestions} + initialValue={urlParams.kuery} + onChange={onChange} + onSubmit={onSubmit} + suggestions={state.suggestions} + placeholder={disabled ? disabledPlaceholder : placeholder} + /> + </Container> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx b/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx rename to x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/index.tsx b/x-pack/plugins/apm/public/components/shared/LicensePrompt/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/LicensePrompt/index.tsx rename to x-pack/plugins/apm/public/components/shared/LicensePrompt/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx similarity index 85% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx index 806d9f73369c3..05e4080d5d0b7 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverErrorLink.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { ERROR_GROUP_ID, SERVICE_NAME -} from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; -import { APMError } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; +} from '../../../../../common/elasticsearch_fieldnames'; +import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; import { DiscoverLink } from './DiscoverLink'; function getDiscoverQuery(error: APMError, kuery?: string) { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx index 6dc93292956fa..b58a450d26644 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx @@ -11,7 +11,7 @@ import url from 'url'; import rison, { RisonValue } from 'rison-node'; import { useLocation } from '../../../../hooks/useLocation'; import { getTimepickerRisonData } from '../rison_helpers'; -import { APM_STATIC_INDEX_PATTERN_ID } from '../../../../../../../../plugins/apm/common/index_pattern_constants'; +import { APM_STATIC_INDEX_PATTERN_ID } from '../../../../../common/index_pattern_constants'; import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; import { AppMountContextBasePath } from '../../../../context/ApmPluginContext'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx similarity index 79% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx index 8fe5be28def22..ac9e33b3acd69 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverSpanLink.tsx @@ -5,8 +5,8 @@ */ import React from 'react'; -import { SPAN_ID } from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; -import { Span } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; +import { SPAN_ID } from '../../../../../common/elasticsearch_fieldnames'; +import { Span } from '../../../../../typings/es_schemas/ui/span'; import { DiscoverLink } from './DiscoverLink'; function getDiscoverQuery(span: Span) { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx similarity index 85% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx index b0af33fd7d7f7..a5f4df7dbac1b 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverTransactionLink.tsx @@ -9,8 +9,8 @@ import { PROCESSOR_EVENT, TRACE_ID, TRANSACTION_ID -} from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +} from '../../../../../common/elasticsearch_fieldnames'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { DiscoverLink } from './DiscoverLink'; export function getDiscoverQuery(transaction: Transaction) { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorButton.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorButton.test.tsx similarity index 94% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorButton.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorButton.test.tsx index eeb9fd20a4bcb..acf8d89432b23 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorButton.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorButton.test.tsx @@ -6,7 +6,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; -import { APMError } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; +import { APMError } from '../../../../../../typings/es_schemas/ui/apm_error'; import { DiscoverErrorLink } from '../DiscoverErrorLink'; describe('DiscoverErrorLink without kuery', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx similarity index 94% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx index eeb9fd20a4bcb..acf8d89432b23 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverErrorLink.test.tsx @@ -6,7 +6,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import React from 'react'; -import { APMError } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; +import { APMError } from '../../../../../../typings/es_schemas/ui/apm_error'; import { DiscoverErrorLink } from '../DiscoverErrorLink'; describe('DiscoverErrorLink without kuery', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx index 759caa785c1af..ea79fe12ff0bd 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx @@ -6,9 +6,9 @@ import { Location } from 'history'; import React from 'react'; -import { APMError } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; -import { Span } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; -import { Transaction } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { APMError } from '../../../../../../typings/es_schemas/ui/apm_error'; +import { Span } from '../../../../../../typings/es_schemas/ui/span'; +import { Transaction } from '../../../../../../typings/es_schemas/ui/transaction'; import { getRenderedHref } from '../../../../../utils/testHelpers'; import { DiscoverErrorLink } from '../DiscoverErrorLink'; import { DiscoverSpanLink } from '../DiscoverSpanLink'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx similarity index 90% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx index d72925c1956a4..5769ca34a9a87 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionButton.test.tsx @@ -6,7 +6,7 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { Transaction } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../../typings/es_schemas/ui/transaction'; import { DiscoverTransactionLink, getDiscoverQuery diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx similarity index 88% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx index 15a92474fcc6d..2f65cf7734631 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverTransactionLink.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Transaction } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../../typings/es_schemas/ui/transaction'; // @ts-ignore import configureStore from '../../../../../store/config/configureStore'; import { getDiscoverQuery } from '../DiscoverTransactionLink'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorButton.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorButton.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorButton.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorButton.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorLink.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorLink.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorLink.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverErrorLink.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionButton.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionButton.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionButton.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionButton.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionLink.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionLink.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionLink.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/__snapshots__/DiscoverTransactionLink.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json b/x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json rename to x-pack/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/mockTransaction.json diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/ElasticDocsLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/InfraLink.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/InfraLink.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/InfraLink.tsx similarity index 94% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/InfraLink.tsx index 7efe5cb96cfbd..0ae9f64dc24ef 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/InfraLink.tsx @@ -10,7 +10,7 @@ import url from 'url'; import { fromQuery } from './url_helpers'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; import { AppMountContextBasePath } from '../../../context/ApmPluginContext'; -import { InfraAppId } from '../../../../../../../plugins/infra/public'; +import { InfraAppId } from '../../../../../infra/public'; interface InfraQueryParams { time?: number; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/KibanaLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/KibanaLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx similarity index 88% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx index ecf788ddd2e69..81c5d17d491c0 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { getMlJobId } from '../../../../../../../../plugins/apm/common/ml_job_constants'; +import { getMlJobId } from '../../../../../common/ml_job_constants'; import { MLLink } from './MLLink'; interface Props { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ErrorDetailLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorDetailLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ErrorDetailLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/ErrorDetailLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx index fcc0dc7d26695..ebcf220994cda 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ErrorOverviewLink.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { pickKeys } from '../../../../utils/pickKeys'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; import { APMQueryParams } from '../url_helpers'; interface Props extends APMLinkExtendProps { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ExternalLinks.test.ts b/x-pack/plugins/apm/public/components/shared/Links/apm/ExternalLinks.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ExternalLinks.test.ts rename to x-pack/plugins/apm/public/components/shared/Links/apm/ExternalLinks.test.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ExternalLinks.ts b/x-pack/plugins/apm/public/components/shared/Links/apm/ExternalLinks.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ExternalLinks.ts rename to x-pack/plugins/apm/public/components/shared/Links/apm/ExternalLinks.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/HomeLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/HomeLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/HomeLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/HomeLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx index 7d21e1efa44f2..bd3e3b36a8601 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/MetricOverviewLink.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { pickKeys } from '../../../../utils/pickKeys'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; interface Props extends APMLinkExtendProps { serviceName: string; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceMapLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceMapLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceMapLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/ServiceMapLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx index 527c3da9e7e1c..1473221cca2be 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeMetricOverviewLink.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { pickKeys } from '../../../../utils/pickKeys'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; interface Props extends APMLinkExtendProps { serviceName: string; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx index db1b6ec117bf4..b479ab77e1127 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceNodeOverviewLink.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { pickKeys } from '../../../../utils/pickKeys'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; interface Props extends APMLinkExtendProps { serviceName: string; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx index 101f1602506aa..577209a26e46b 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/ServiceOverviewLink.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { pickKeys } from '../../../../utils/pickKeys'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; const ServiceOverviewLink = (props: APMLinkExtendProps) => { const { urlParams } = useUrlParams(); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/SettingsLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/SettingsLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/SettingsLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/SettingsLink.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx index 371544c142a2d..dc4519365cbc2 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/TraceOverviewLink.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { pickKeys } from '../../../../utils/pickKeys'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; const TraceOverviewLink = (props: APMLinkExtendProps) => { const { urlParams } = useUrlParams(); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx similarity index 94% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx index 784f9b36ff621..6278336751851 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionDetailLink.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { pickKeys } from '../../../../utils/pickKeys'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; interface Props extends APMLinkExtendProps { serviceName: string; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx index af60a0a748445..ccef83ee73fb8 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { APMLink, APMLinkExtendProps } from './APMLink'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { pickKeys } from '../../../../utils/pickKeys'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; interface Props extends APMLinkExtendProps { serviceName: string; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx similarity index 87% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx rename to x-pack/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx index 0c747e0773a69..6885e44f1ad1f 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx @@ -5,7 +5,7 @@ */ import { getAPMHref } from './APMLink'; -import { AgentConfigurationIntake } from '../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { AgentConfigurationIntake } from '../../../../../common/agent_configuration/configuration_types'; import { history } from '../../../../utils/history'; export function editAgentConfigurationHref( diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/rison_helpers.ts b/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/rison_helpers.ts rename to x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.test.tsx rename to x-pack/plugins/apm/public/components/shared/Links/url_helpers.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.ts b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts similarity index 87% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.ts rename to x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts index c7d71d0b6dac5..b296302c47edf 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts @@ -6,8 +6,8 @@ import { parse, stringify } from 'query-string'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { LocalUIFilterName } from '../../../../../../../plugins/apm/server/lib/ui_filters/local_ui_filters/config'; -import { url } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { LocalUIFilterName } from '../../../../server/lib/ui_filters/local_ui_filters/config'; +import { url } from '../../../../../../../src/plugins/kibana_utils/public'; export function toQuery(search?: string): APMQueryParamsRaw { return search ? parse(search.slice(1), { sort: false }) : {}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx b/x-pack/plugins/apm/public/components/shared/LoadingStatePrompt.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/LoadingStatePrompt.tsx rename to x-pack/plugins/apm/public/components/shared/LoadingStatePrompt.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx rename to x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterBadgeList.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx rename to x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/FilterTitleButton.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx rename to x-pack/plugins/apm/public/components/shared/LocalUIFilters/Filter/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx rename to x-pack/plugins/apm/public/components/shared/LocalUIFilters/TransactionTypeFilter/index.tsx diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx new file mode 100644 index 0000000000000..2c755009ed13a --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/index.tsx @@ -0,0 +1,95 @@ +/* + * 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 { + EuiTitle, + EuiSpacer, + EuiHorizontalRule, + EuiButtonEmpty +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { LocalUIFilterName } from '../../../../server/lib/ui_filters/local_ui_filters/config'; +import { Filter } from './Filter'; +import { useLocalUIFilters } from '../../../hooks/useLocalUIFilters'; +import { PROJECTION } from '../../../../common/projections/typings'; + +interface Props { + projection: PROJECTION; + filterNames: LocalUIFilterName[]; + params?: Record<string, string | number | boolean | undefined>; + showCount?: boolean; + children?: React.ReactNode; +} + +const ButtonWrapper = styled.div` + display: inline-block; +`; + +const LocalUIFilters = ({ + projection, + params, + filterNames, + children, + showCount = true +}: Props) => { + const { filters, setFilterValue, clearValues } = useLocalUIFilters({ + filterNames, + projection, + params + }); + + const hasValues = filters.some(filter => filter.value.length > 0); + + return ( + <> + <EuiTitle size="s"> + <h3> + {i18n.translate('xpack.apm.localFiltersTitle', { + defaultMessage: 'Filters' + })} + </h3> + </EuiTitle> + <EuiSpacer size="s" /> + {children} + {filters.map(filter => { + return ( + <React.Fragment key={filter.name}> + <Filter + {...filter} + onChange={value => { + setFilterValue(filter.name, value); + }} + showCount={showCount} + /> + <EuiHorizontalRule margin="none" /> + </React.Fragment> + ); + })} + {hasValues ? ( + <> + <EuiSpacer size="s" /> + <ButtonWrapper> + <EuiButtonEmpty + size="xs" + iconType="cross" + flush="left" + onClick={clearValues} + > + {i18n.translate('xpack.apm.clearFilters', { + defaultMessage: 'Clear filters' + })} + </EuiButtonEmpty> + </ButtonWrapper> + </> + ) : null} + </> + ); +}; + +export { LocalUIFilters }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/__test__/ManagedTable.test.js b/x-pack/plugins/apm/public/components/shared/ManagedTable/__test__/ManagedTable.test.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/__test__/ManagedTable.test.js rename to x-pack/plugins/apm/public/components/shared/ManagedTable/__test__/ManagedTable.test.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/__test__/__snapshots__/ManagedTable.test.js.snap b/x-pack/plugins/apm/public/components/shared/ManagedTable/__test__/__snapshots__/ManagedTable.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/__test__/__snapshots__/ManagedTable.test.js.snap rename to x-pack/plugins/apm/public/components/shared/ManagedTable/__test__/__snapshots__/ManagedTable.test.js.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/index.tsx b/x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ManagedTable/index.tsx rename to x-pack/plugins/apm/public/components/shared/ManagedTable/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx rename to x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx index 258788252379a..1913cf79c7935 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { ErrorMetadata } from '..'; import { render } from '@testing-library/react'; -import { APMError } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; +import { APMError } from '../../../../../../typings/es_schemas/ui/apm_error'; import { expectTextsInDocument, expectTextsNotInDocument diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.tsx new file mode 100644 index 0000000000000..7cae42a94322b --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/index.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 React, { useMemo } from 'react'; +import { ERROR_METADATA_SECTIONS } from './sections'; +import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; +import { getSectionsWithRows } from '../helper'; +import { MetadataTable } from '..'; + +interface Props { + error: APMError; +} + +export function ErrorMetadata({ error }: Props) { + const sectionsWithRows = useMemo( + () => getSectionsWithRows(ERROR_METADATA_SECTIONS, error), + [error] + ); + return <MetadataTable sections={sectionsWithRows} />; +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/sections.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/sections.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/sections.ts rename to x-pack/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/sections.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/Section.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/Section.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/Section.tsx rename to x-pack/plugins/apm/public/components/shared/MetadataTable/Section.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx similarity index 96% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx rename to x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx index 0059b7b8fb4b3..a46539fe72fcb 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { SpanMetadata } from '..'; -import { Span } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; +import { Span } from '../../../../../../typings/es_schemas/ui/span'; import { expectTextsInDocument, expectTextsNotInDocument diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.tsx new file mode 100644 index 0000000000000..abef083e39b9e --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/index.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 React, { useMemo } from 'react'; +import { SPAN_METADATA_SECTIONS } from './sections'; +import { Span } from '../../../../../typings/es_schemas/ui/span'; +import { getSectionsWithRows } from '../helper'; +import { MetadataTable } from '..'; + +interface Props { + span: Span; +} + +export function SpanMetadata({ span }: Props) { + const sectionsWithRows = useMemo( + () => getSectionsWithRows(SPAN_METADATA_SECTIONS, span), + [span] + ); + return <MetadataTable sections={sectionsWithRows} />; +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/sections.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/sections.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/sections.ts rename to x-pack/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/sections.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx rename to x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx index 3d78f36db9786..8ae46d359efc3 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { TransactionMetadata } from '..'; import { render } from '@testing-library/react'; -import { Transaction } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../../typings/es_schemas/ui/transaction'; import { expectTextsInDocument, expectTextsNotInDocument diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.tsx new file mode 100644 index 0000000000000..86ecbba6a0aaa --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/index.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 React, { useMemo } from 'react'; +import { TRANSACTION_METADATA_SECTIONS } from './sections'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; +import { getSectionsWithRows } from '../helper'; +import { MetadataTable } from '..'; + +interface Props { + transaction: Transaction; +} + +export function TransactionMetadata({ transaction }: Props) { + const sectionsWithRows = useMemo( + () => getSectionsWithRows(TRANSACTION_METADATA_SECTIONS, transaction), + [transaction] + ); + return <MetadataTable sections={sectionsWithRows} />; +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/sections.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/sections.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/sections.ts rename to x-pack/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/sections.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx rename to x-pack/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/Section.test.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/__test__/Section.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/Section.test.tsx rename to x-pack/plugins/apm/public/components/shared/MetadataTable/__test__/Section.test.tsx diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/__test__/helper.test.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/__test__/helper.test.ts new file mode 100644 index 0000000000000..e754f7163ca11 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/__test__/helper.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { getSectionsWithRows, filterSectionsByTerm } from '../helper'; +import { LABELS, HTTP, SERVICE } from '../sections'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; + +describe('MetadataTable Helper', () => { + const sections = [ + { ...LABELS, required: true }, + HTTP, + { ...SERVICE, properties: ['environment'] } + ]; + const apmDoc = ({ + http: { + headers: { + Connection: 'close', + Host: 'opbeans:3000', + request: { method: 'get' } + } + }, + service: { + framework: { name: 'express' }, + environment: 'production' + } + } as unknown) as Transaction; + const metadataItems = getSectionsWithRows(sections, apmDoc); + + it('returns flattened data and required section', () => { + expect(metadataItems).toEqual([ + { key: 'labels', label: 'Labels', required: true, rows: [] }, + { + key: 'http', + label: 'HTTP', + rows: [ + { key: 'http.headers.Connection', value: 'close' }, + { key: 'http.headers.Host', value: 'opbeans:3000' }, + { key: 'http.headers.request.method', value: 'get' } + ] + }, + { + key: 'service', + label: 'Service', + properties: ['environment'], + rows: [{ key: 'service.environment', value: 'production' }] + } + ]); + }); + describe('filter', () => { + it('items by key', () => { + const filteredItems = filterSectionsByTerm(metadataItems, 'http'); + expect(filteredItems).toEqual([ + { + key: 'http', + label: 'HTTP', + rows: [ + { key: 'http.headers.Connection', value: 'close' }, + { key: 'http.headers.Host', value: 'opbeans:3000' }, + { key: 'http.headers.request.method', value: 'get' } + ] + } + ]); + }); + + it('items by value', () => { + const filteredItems = filterSectionsByTerm(metadataItems, 'product'); + expect(filteredItems).toEqual([ + { + key: 'service', + label: 'Service', + properties: ['environment'], + rows: [{ key: 'service.environment', value: 'production' }] + } + ]); + }); + + it('returns empty when no item matches', () => { + const filteredItems = filterSectionsByTerm(metadataItems, 'post'); + expect(filteredItems).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.ts new file mode 100644 index 0000000000000..a8678ee596e43 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/MetadataTable/helper.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 { get, pick, isEmpty } from 'lodash'; +import { Section } from './sections'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; +import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; +import { Span } from '../../../../typings/es_schemas/ui/span'; +import { flattenObject, KeyValuePair } from '../../../utils/flattenObject'; + +export type SectionsWithRows = ReturnType<typeof getSectionsWithRows>; + +export const getSectionsWithRows = ( + sections: Section[], + apmDoc: Transaction | APMError | Span +) => { + return sections + .map(section => { + const sectionData: Record<string, unknown> = get(apmDoc, section.key); + const filteredData: + | Record<string, unknown> + | undefined = section.properties + ? pick(sectionData, section.properties) + : sectionData; + + const rows: KeyValuePair[] = flattenObject(filteredData, section.key); + return { ...section, rows }; + }) + .filter(({ required, rows }) => required || !isEmpty(rows)); +}; + +export const filterSectionsByTerm = ( + sections: SectionsWithRows, + searchTerm: string +) => { + if (!searchTerm) { + return sections; + } + return sections + .map(section => { + const { rows = [] } = section; + const filteredRows = rows.filter(({ key, value }) => { + const valueAsString = String(value).toLowerCase(); + return ( + key.toLowerCase().includes(searchTerm) || + valueAsString.includes(searchTerm) + ); + }); + return { ...section, rows: filteredRows }; + }) + .filter(({ rows }) => !isEmpty(rows)); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/index.tsx b/x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/index.tsx rename to x-pack/plugins/apm/public/components/shared/MetadataTable/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/sections.ts b/x-pack/plugins/apm/public/components/shared/MetadataTable/sections.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/sections.ts rename to x-pack/plugins/apm/public/components/shared/MetadataTable/sections.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx b/x-pack/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx rename to x-pack/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx b/x-pack/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx rename to x-pack/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.test.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.test.tsx rename to x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx rename to x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx index eab414ad47c2c..6dfc8778fe1fc 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/CauseStacktrace.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiAccordion, EuiTitle } from '@elastic/eui'; import { px, unit } from '../../../style/variables'; import { Stacktrace } from '.'; -import { IStackframe } from '../../../../../../../plugins/apm/typings/es_schemas/raw/fields/stackframe'; +import { IStackframe } from '../../../../typings/es_schemas/raw/fields/stackframe'; // @ts-ignore Styled Components has trouble inferring the types of the default props here. const Accordion = styled(EuiAccordion)` diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/Context.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/Context.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/Context.tsx rename to x-pack/plugins/apm/public/components/shared/Stacktrace/Context.tsx index d289539ca44b1..d48f4b4f51a6a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/Context.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/Context.tsx @@ -22,7 +22,7 @@ import { registerLanguage } from 'react-syntax-highlighter/dist/light'; // @ts-ignore import { xcode } from 'react-syntax-highlighter/dist/styles'; import styled from 'styled-components'; -import { IStackframeWithLineContext } from '../../../../../../../plugins/apm/typings/es_schemas/raw/fields/stackframe'; +import { IStackframeWithLineContext } from '../../../../typings/es_schemas/raw/fields/stackframe'; import { borderRadius, px, unit, units } from '../../../style/variables'; registerLanguage('javascript', javascript); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx rename to x-pack/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx index daa722255bdf3..4467fe7ad615e 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx @@ -7,7 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import React, { Fragment } from 'react'; import styled from 'styled-components'; -import { IStackframe } from '../../../../../../../plugins/apm/typings/es_schemas/raw/fields/stackframe'; +import { IStackframe } from '../../../../typings/es_schemas/raw/fields/stackframe'; import { fontFamilyCode, fontSize, px, units } from '../../../style/variables'; const FileDetails = styled.div` diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.test.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.test.tsx rename to x-pack/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.tsx rename to x-pack/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.tsx index be6595153aa77..009e97358428c 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/LibraryStacktrace.tsx @@ -8,7 +8,7 @@ import { EuiAccordion } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import styled from 'styled-components'; -import { IStackframe } from '../../../../../../../plugins/apm/typings/es_schemas/raw/fields/stackframe'; +import { IStackframe } from '../../../../typings/es_schemas/raw/fields/stackframe'; import { Stackframe } from './Stackframe'; import { px, unit } from '../../../style/variables'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx similarity index 96% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx rename to x-pack/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx index 404d474a7960a..4c55add56bc40 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/Stackframe.tsx @@ -11,7 +11,7 @@ import { EuiAccordion } from '@elastic/eui'; import { IStackframe, IStackframeWithLineContext -} from '../../../../../../../plugins/apm/typings/es_schemas/raw/fields/stackframe'; +} from '../../../../typings/es_schemas/raw/fields/stackframe'; import { borderRadius, fontFamilyCode, diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/Variables.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/Variables.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/Variables.tsx rename to x-pack/plugins/apm/public/components/shared/Stacktrace/Variables.tsx index 0786116a659c7..ec5fb39f83f8c 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/Variables.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/Variables.tsx @@ -10,7 +10,7 @@ import { EuiAccordion } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { borderRadius, px, unit, units } from '../../../style/variables'; -import { IStackframe } from '../../../../../../../plugins/apm/typings/es_schemas/raw/fields/stackframe'; +import { IStackframe } from '../../../../typings/es_schemas/raw/fields/stackframe'; import { KeyValueTable } from '../KeyValueTable'; import { flattenObject } from '../../../utils/flattenObject'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx rename to x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx index 1b2268326e6be..478f9cfe921d9 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx @@ -6,7 +6,7 @@ import { mount, ReactWrapper, shallow } from 'enzyme'; import React from 'react'; -import { IStackframe } from '../../../../../../../../plugins/apm/typings/es_schemas/raw/fields/stackframe'; +import { IStackframe } from '../../../../../typings/es_schemas/raw/fields/stackframe'; import { Stackframe } from '../Stackframe'; import stacktracesMock from './stacktraces.json'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/index.test.ts.snap b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/index.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/index.test.ts.snap rename to x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/index.test.ts.snap diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/index.test.ts b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/index.test.ts new file mode 100644 index 0000000000000..22357b9590887 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/index.test.ts @@ -0,0 +1,173 @@ +/* + * 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 { IStackframe } from '../../../../../typings/es_schemas/raw/fields/stackframe'; +import { getGroupedStackframes } from '../index'; +import stacktracesMock from './stacktraces.json'; + +describe('Stacktrace/index', () => { + describe('getGroupedStackframes', () => { + it('should collapse the library frames into a set of grouped stackframes', () => { + const result = getGroupedStackframes(stacktracesMock as IStackframe[]); + expect(result).toMatchSnapshot(); + }); + + it('should group stackframes when `library_frame` is identical and `exclude_from_grouping` is false', () => { + const stackframes = [ + { + library_frame: false, + exclude_from_grouping: false, + filename: 'file-a.txt' + }, + { + library_frame: false, + exclude_from_grouping: false, + filename: 'file-b.txt' + }, + { + library_frame: true, + exclude_from_grouping: false, + filename: 'file-c.txt' + }, + { + library_frame: true, + exclude_from_grouping: false, + filename: 'file-d.txt' + } + ] as IStackframe[]; + + const result = getGroupedStackframes(stackframes); + + expect(result).toEqual([ + { + excludeFromGrouping: false, + isLibraryFrame: false, + stackframes: [ + { + exclude_from_grouping: false, + filename: 'file-a.txt', + library_frame: false + }, + { + exclude_from_grouping: false, + filename: 'file-b.txt', + library_frame: false + } + ] + }, + { + excludeFromGrouping: false, + isLibraryFrame: true, + stackframes: [ + { + exclude_from_grouping: false, + filename: 'file-c.txt', + library_frame: true + }, + { + exclude_from_grouping: false, + filename: 'file-d.txt', + library_frame: true + } + ] + } + ]); + }); + + it('should not group stackframes when `library_frame` is the different', () => { + const stackframes = [ + { + library_frame: false, + exclude_from_grouping: false, + filename: 'file-a.txt' + }, + { + library_frame: true, + exclude_from_grouping: false, + filename: 'file-b.txt' + } + ] as IStackframe[]; + const result = getGroupedStackframes(stackframes); + expect(result).toEqual([ + { + excludeFromGrouping: false, + isLibraryFrame: false, + stackframes: [ + { + exclude_from_grouping: false, + filename: 'file-a.txt', + library_frame: false + } + ] + }, + { + excludeFromGrouping: false, + isLibraryFrame: true, + stackframes: [ + { + exclude_from_grouping: false, + filename: 'file-b.txt', + library_frame: true + } + ] + } + ]); + }); + + it('should not group stackframes when `exclude_from_grouping` is true', () => { + const stackframes = [ + { + library_frame: false, + exclude_from_grouping: false, + filename: 'file-a.txt' + }, + { + library_frame: false, + exclude_from_grouping: true, + filename: 'file-b.txt' + } + ] as IStackframe[]; + const result = getGroupedStackframes(stackframes); + expect(result).toEqual([ + { + excludeFromGrouping: false, + isLibraryFrame: false, + stackframes: [ + { + exclude_from_grouping: false, + filename: 'file-a.txt', + library_frame: false + } + ] + }, + { + excludeFromGrouping: true, + isLibraryFrame: false, + stackframes: [ + { + exclude_from_grouping: true, + filename: 'file-b.txt', + library_frame: false + } + ] + } + ]); + }); + + it('should handle empty stackframes', () => { + const result = getGroupedStackframes([] as IStackframe[]); + expect(result).toHaveLength(0); + }); + + it('should handle one stackframe', () => { + const result = getGroupedStackframes([ + stacktracesMock[0] + ] as IStackframe[]); + expect(result).toHaveLength(1); + expect(result[0].stackframes).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraces.json b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraces.json similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraces.json rename to x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/stacktraces.json diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/index.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/index.tsx new file mode 100644 index 0000000000000..b6435f7c42183 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/index.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEmpty, last } from 'lodash'; +import React, { Fragment } from 'react'; +import { IStackframe } from '../../../../typings/es_schemas/raw/fields/stackframe'; +import { EmptyMessage } from '../../shared/EmptyMessage'; +import { LibraryStacktrace } from './LibraryStacktrace'; +import { Stackframe } from './Stackframe'; + +interface Props { + stackframes?: IStackframe[]; + codeLanguage?: string; +} + +export function Stacktrace({ stackframes = [], codeLanguage }: Props) { + if (isEmpty(stackframes)) { + return ( + <EmptyMessage + heading={i18n.translate( + 'xpack.apm.stacktraceTab.noStacktraceAvailableLabel', + { + defaultMessage: 'No stack trace available.' + } + )} + hideSubheading + /> + ); + } + + const groups = getGroupedStackframes(stackframes); + + return ( + <Fragment> + {groups.map((group, i) => { + // library frame + if (group.isLibraryFrame && groups.length > 1) { + return ( + <Fragment key={i}> + <EuiSpacer size="m" /> + <LibraryStacktrace + id={i.toString()} + stackframes={group.stackframes} + codeLanguage={codeLanguage} + /> + <EuiSpacer size="m" /> + </Fragment> + ); + } + + // non-library frame + return group.stackframes.map((stackframe, idx) => ( + <Fragment key={`${i}-${idx}`}> + {idx > 0 && <EuiSpacer size="m" />} + <Stackframe + codeLanguage={codeLanguage} + id={`${i}-${idx}`} + initialIsOpen={i === 0 && groups.length > 1} + stackframe={stackframe} + /> + </Fragment> + )); + })} + <EuiSpacer size="m" /> + </Fragment> + ); +} + +interface StackframesGroup { + isLibraryFrame: boolean; + excludeFromGrouping: boolean; + stackframes: IStackframe[]; +} + +export function getGroupedStackframes(stackframes: IStackframe[]) { + return stackframes.reduce((acc, stackframe) => { + const prevGroup = last(acc); + const shouldAppend = + prevGroup && + prevGroup.isLibraryFrame === stackframe.library_frame && + !prevGroup.excludeFromGrouping && + !stackframe.exclude_from_grouping; + + // append to group + if (shouldAppend) { + prevGroup.stackframes.push(stackframe); + return acc; + } + + // create new group + acc.push({ + isLibraryFrame: Boolean(stackframe.library_frame), + excludeFromGrouping: Boolean(stackframe.exclude_from_grouping), + stackframes: [stackframe] + }); + return acc; + }, [] as StackframesGroup[]); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/StickyProperties/StickyProperties.test.js b/x-pack/plugins/apm/public/components/shared/StickyProperties/StickyProperties.test.js similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/shared/StickyProperties/StickyProperties.test.js rename to x-pack/plugins/apm/public/components/shared/StickyProperties/StickyProperties.test.js index 08283dee3825d..b6acb6904f865 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/StickyProperties/StickyProperties.test.js +++ b/x-pack/plugins/apm/public/components/shared/StickyProperties/StickyProperties.test.js @@ -7,10 +7,7 @@ import React from 'react'; import { StickyProperties } from './index'; import { shallow } from 'enzyme'; -import { - USER_ID, - URL_FULL -} from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +import { USER_ID, URL_FULL } from '../../../../common/elasticsearch_fieldnames'; import { mockMoment } from '../../../utils/testHelpers'; describe('StickyProperties', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/StickyProperties/__snapshots__/StickyProperties.test.js.snap b/x-pack/plugins/apm/public/components/shared/StickyProperties/__snapshots__/StickyProperties.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/StickyProperties/__snapshots__/StickyProperties.test.js.snap rename to x-pack/plugins/apm/public/components/shared/StickyProperties/__snapshots__/StickyProperties.test.js.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/StickyProperties/index.tsx b/x-pack/plugins/apm/public/components/shared/StickyProperties/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/StickyProperties/index.tsx rename to x-pack/plugins/apm/public/components/shared/StickyProperties/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx b/x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/DurationSummaryItem.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx b/x-pack/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/ErrorCountSummaryItemBadge.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/__test__/HttpInfoSummaryItem.test.tsx b/x-pack/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/__test__/HttpInfoSummaryItem.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/__test__/HttpInfoSummaryItem.test.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/__test__/HttpInfoSummaryItem.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx b/x-pack/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/HttpInfoSummaryItem/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/__test__/HttpStatusBadge.test.tsx b/x-pack/plugins/apm/public/components/shared/Summary/HttpStatusBadge/__test__/HttpStatusBadge.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/__test__/HttpStatusBadge.test.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/HttpStatusBadge/__test__/HttpStatusBadge.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/index.tsx b/x-pack/plugins/apm/public/components/shared/Summary/HttpStatusBadge/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/index.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/HttpStatusBadge/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/statusCodes.ts b/x-pack/plugins/apm/public/components/shared/Summary/HttpStatusBadge/statusCodes.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/HttpStatusBadge/statusCodes.ts rename to x-pack/plugins/apm/public/components/shared/Summary/HttpStatusBadge/statusCodes.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/TransactionResultSummaryItem.tsx b/x-pack/plugins/apm/public/components/shared/Summary/TransactionResultSummaryItem.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/TransactionResultSummaryItem.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/TransactionResultSummaryItem.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/TransactionSummary.test.tsx b/x-pack/plugins/apm/public/components/shared/Summary/TransactionSummary.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/TransactionSummary.test.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/TransactionSummary.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx b/x-pack/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx similarity index 91% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx index f0fe57e46f2fe..f24a806426510 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx +++ b/x-pack/plugins/apm/public/components/shared/Summary/TransactionSummary.tsx @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { Transaction } from '../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { Summary } from './'; import { TimestampTooltip } from '../TimestampTooltip'; import { DurationSummaryItem } from './DurationSummaryItem'; import { ErrorCountSummaryItemBadge } from './ErrorCountSummaryItemBadge'; -import { isRumAgentName } from '../../../../../../../plugins/apm/common/agent_name'; +import { isRumAgentName } from '../../../../common/agent_name'; import { HttpInfoSummaryItem } from './HttpInfoSummaryItem'; import { TransactionResultSummaryItem } from './TransactionResultSummaryItem'; import { UserAgentSummaryItem } from './UserAgentSummaryItem'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.test.tsx b/x-pack/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.test.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.tsx b/x-pack/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.tsx similarity index 90% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.tsx index 10a6bcc1ef7bd..8173170b72f23 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.tsx +++ b/x-pack/plugins/apm/public/components/shared/Summary/UserAgentSummaryItem.tsx @@ -9,7 +9,7 @@ import styled from 'styled-components'; import theme from '@elastic/eui/dist/eui_theme_light.json'; import { EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { UserAgent } from '../../../../../../../plugins/apm/typings/es_schemas/raw/fields/user_agent'; +import { UserAgent } from '../../../../typings/es_schemas/raw/fields/user_agent'; type UserAgentSummaryItemProps = UserAgent; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/__fixtures__/transactions.ts b/x-pack/plugins/apm/public/components/shared/Summary/__fixtures__/transactions.ts similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/__fixtures__/transactions.ts rename to x-pack/plugins/apm/public/components/shared/Summary/__fixtures__/transactions.ts index 05fb73a9e2749..e1615934cd92e 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Summary/__fixtures__/transactions.ts +++ b/x-pack/plugins/apm/public/components/shared/Summary/__fixtures__/transactions.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; export const httpOk: Transaction = { '@timestamp': '0', diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Summary/__test__/ErrorCountSummaryItemBadge.test.tsx b/x-pack/plugins/apm/public/components/shared/Summary/__test__/ErrorCountSummaryItemBadge.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/Summary/__test__/ErrorCountSummaryItemBadge.test.tsx rename to x-pack/plugins/apm/public/components/shared/Summary/__test__/ErrorCountSummaryItemBadge.test.tsx diff --git a/x-pack/plugins/apm/public/components/shared/Summary/index.tsx b/x-pack/plugins/apm/public/components/shared/Summary/index.tsx new file mode 100644 index 0000000000000..ce6935d1858aa --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Summary/index.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { px, units } from '../../../../public/style/variables'; +import { Maybe } from '../../../../typings/common'; + +interface Props { + items: Array<Maybe<React.ReactElement>>; +} + +// TODO: Light/Dark theme (@see https://github.com/elastic/kibana/issues/44840) +const theme = euiLightVars; + +const Item = styled(EuiFlexItem)` + flex-wrap: nowrap; + border-right: 1px solid ${theme.euiColorLightShade}; + padding-right: ${px(units.half)}; + flex-flow: row nowrap; + line-height: 1.5; + align-items: center !important; + &:last-child { + border-right: none; + padding-right: 0; + } +`; + +const Summary = ({ items }: Props) => { + const filteredItems = items.filter(Boolean) as React.ReactElement[]; + + return ( + <EuiFlexGrid gutterSize="s"> + {filteredItems.map((item, index) => ( + <Item key={index} grow={false}> + {item} + </Item> + ))} + </EuiFlexGrid> + ); +}; + +export { Summary }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TimestampTooltip/__test__/index.test.tsx b/x-pack/plugins/apm/public/components/shared/TimestampTooltip/__test__/index.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/TimestampTooltip/__test__/index.test.tsx rename to x-pack/plugins/apm/public/components/shared/TimestampTooltip/__test__/index.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TimestampTooltip/index.tsx b/x-pack/plugins/apm/public/components/shared/TimestampTooltip/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/TimestampTooltip/index.tsx rename to x-pack/plugins/apm/public/components/shared/TimestampTooltip/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx similarity index 91% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx index 8df6d952cfacd..49f8fddb303bd 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.test.tsx @@ -5,10 +5,10 @@ */ import React from 'react'; import { render, act, fireEvent } from '@testing-library/react'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { CustomLinkPopover } from './CustomLinkPopover'; import { expectTextsInDocument } from '../../../../utils/testHelpers'; -import { CustomLink } from '../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; +import { CustomLink } from '../../../../../common/custom_link/custom_link_types'; describe('CustomLinkPopover', () => { const customLinks = [ diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx similarity index 90% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx index 3aed1b7ac2953..a63c226a5c46e 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkPopover.tsx @@ -12,8 +12,8 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; -import { CustomLink } from '../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLink } from '../../../../../common/custom_link/custom_link_types'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { CustomLinkSection } from './CustomLinkSection'; import { ManageCustomLink } from './ManageCustomLink'; import { px } from '../../../../style/variables'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx similarity index 85% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx index d429fa56894eb..6cf8b9ee5e98a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.test.tsx @@ -10,8 +10,8 @@ import { expectTextsInDocument, expectTextsNotInDocument } from '../../../../utils/testHelpers'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; -import { CustomLink } from '../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; +import { CustomLink } from '../../../../../common/custom_link/custom_link_types'; describe('CustomLinkSection', () => { const customLinks = [ diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx similarity index 86% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx index bd00bcf600ffe..e22f4b4a37745 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/CustomLinkSection.tsx @@ -7,8 +7,8 @@ import { EuiLink, EuiText } from '@elastic/eui'; import Mustache from 'mustache'; import React from 'react'; import styled from 'styled-components'; -import { CustomLink } from '../../../../../../../../plugins/apm/common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { CustomLink } from '../../../../../common/custom_link/custom_link_types'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { px, truncate, units } from '../../../../style/variables'; const LinkContainer = styled.li` diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/ManageCustomLink.tsx diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx new file mode 100644 index 0000000000000..c7a2d77d85fa6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.test.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { CustomLink } from '.'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { + expectTextsInDocument, + expectTextsNotInDocument +} from '../../../../utils/testHelpers'; +import { CustomLink as CustomLinkType } from '../../../../../common/custom_link/custom_link_types'; + +describe('Custom links', () => { + it('shows empty message when no custom link is available', () => { + const component = render( + <CustomLink + customLinks={[]} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + + expectTextsInDocument(component, [ + 'No custom links found. Set up your own custom links, e.g., a link to a specific Dashboard or external link.' + ]); + expectTextsNotInDocument(component, ['Create']); + }); + + it('shows loading while custom links are fetched', () => { + const { getByTestId } = render( + <CustomLink + customLinks={[]} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.LOADING} + /> + ); + expect(getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('shows first 3 custom links available', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' } + ] as CustomLinkType[]; + const component = render( + <CustomLink + customLinks={customLinks} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + expectTextsInDocument(component, ['foo', 'bar', 'baz']); + expectTextsNotInDocument(component, ['qux']); + }); + + it('clicks on See more button', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' } + ] as CustomLinkType[]; + const onSeeMoreClickMock = jest.fn(); + const component = render( + <CustomLink + customLinks={customLinks} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={onSeeMoreClickMock} + status={FETCH_STATUS.SUCCESS} + /> + ); + expect(onSeeMoreClickMock).not.toHaveBeenCalled(); + act(() => { + fireEvent.click(component.getByText('See more')); + }); + expect(onSeeMoreClickMock).toHaveBeenCalled(); + }); + + describe('create custom link buttons', () => { + it('shows create button below empty message', () => { + const component = render( + <CustomLink + customLinks={[]} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + + expectTextsInDocument(component, ['Create custom link']); + expectTextsNotInDocument(component, ['Create']); + }); + it('shows create button besides the title', () => { + const customLinks = [ + { id: '1', label: 'foo', url: 'foo' }, + { id: '2', label: 'bar', url: 'bar' }, + { id: '3', label: 'baz', url: 'baz' }, + { id: '4', label: 'qux', url: 'qux' } + ] as CustomLinkType[]; + const component = render( + <CustomLink + customLinks={customLinks} + transaction={({} as unknown) as Transaction} + onCreateCustomLinkClick={jest.fn()} + onSeeMoreClick={jest.fn()} + status={FETCH_STATUS.SUCCESS} + /> + ); + expectTextsInDocument(component, ['Create']); + expectTextsNotInDocument(component, ['Create custom link']); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx new file mode 100644 index 0000000000000..710b2175e3377 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLink/index.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiText, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiButtonEmpty +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; +import { isEmpty } from 'lodash'; +import { CustomLink as CustomLinkType } from '../../../../../common/custom_link/custom_link_types'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; +import { + ActionMenuDivider, + SectionSubtitle +} from '../../../../../../observability/public'; +import { CustomLinkSection } from './CustomLinkSection'; +import { ManageCustomLink } from './ManageCustomLink'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { LoadingStatePrompt } from '../../LoadingStatePrompt'; +import { px } from '../../../../style/variables'; + +const SeeMoreButton = styled.button<{ show: boolean }>` + display: ${props => (props.show ? 'flex' : 'none')}; + align-items: center; + width: 100%; + justify-content: space-between; + &:hover { + text-decoration: underline; + } +`; + +export const CustomLink = ({ + customLinks, + status, + onCreateCustomLinkClick, + onSeeMoreClick, + transaction +}: { + customLinks: CustomLinkType[]; + status: FETCH_STATUS; + onCreateCustomLinkClick: () => void; + onSeeMoreClick: () => void; + transaction: Transaction; +}) => { + const renderEmptyPrompt = ( + <> + <EuiText size="xs" grow={false} style={{ width: px(300) }}> + {i18n.translate('xpack.apm.customLink.empty', { + defaultMessage: + 'No custom links found. Set up your own custom links, e.g., a link to a specific Dashboard or external link.' + })} + </EuiText> + <EuiSpacer size="s" /> + <EuiButtonEmpty + iconType="plusInCircle" + size="xs" + onClick={onCreateCustomLinkClick} + > + {i18n.translate('xpack.apm.customLink.buttom.create', { + defaultMessage: 'Create custom link' + })} + </EuiButtonEmpty> + </> + ); + + const renderCustomLinkBottomSection = isEmpty(customLinks) ? ( + renderEmptyPrompt + ) : ( + <SeeMoreButton onClick={onSeeMoreClick} show={customLinks.length > 3}> + <EuiText size="s"> + {i18n.translate('xpack.apm.transactionActionMenu.customLink.seeMore', { + defaultMessage: 'See more' + })} + </EuiText> + <EuiIcon type="arrowRight" /> + </SeeMoreButton> + ); + + return ( + <> + <ActionMenuDivider /> + <EuiFlexGroup> + <EuiFlexItem style={{ justifyContent: 'center' }}> + <EuiText size={'s'} grow={false}> + <h5> + {i18n.translate( + 'xpack.apm.transactionActionMenu.customLink.section', + { + defaultMessage: 'Custom Links' + } + )} + </h5> + </EuiText> + </EuiFlexItem> + <EuiFlexItem> + <ManageCustomLink + onCreateCustomLinkClick={onCreateCustomLinkClick} + showCreateCustomLinkButton={!!customLinks.length} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="s" /> + <SectionSubtitle> + {i18n.translate('xpack.apm.transactionActionMenu.customLink.subtitle', { + defaultMessage: 'Links will open in a new window.' + })} + </SectionSubtitle> + <CustomLinkSection + customLinks={customLinks.slice(0, 3)} + transaction={transaction} + /> + <EuiSpacer size="s" /> + {status === FETCH_STATUS.LOADING ? ( + <LoadingStatePrompt /> + ) : ( + renderCustomLinkBottomSection + )} + </> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index 7ebfe26b83630..c9376cdc01b5b 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -7,8 +7,8 @@ import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { FunctionComponent, useMemo, useState } from 'react'; -import { Filter } from '../../../../../../../plugins/apm/common/custom_link/custom_link_types'; -import { Transaction } from '../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Filter } from '../../../../common/custom_link/custom_link_types'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { ActionMenu, ActionMenuDivider, @@ -17,7 +17,7 @@ import { SectionLinks, SectionSubtitle, SectionTitle -} from '../../../../../../../plugins/observability/public'; +} from '../../../../../observability/public'; import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; import { useFetcher } from '../../../hooks/useFetcher'; import { useLocation } from '../../../hooks/useLocation'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx similarity index 98% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index 8dc2076eab5b5..cda602204469c 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render, fireEvent, act, wait } from '@testing-library/react'; import { TransactionActionMenu } from '../TransactionActionMenu'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import * as Transactions from './mockData'; import { expectTextsNotInDocument, @@ -15,7 +15,7 @@ import { } from '../../../../utils/testHelpers'; import * as hooks from '../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../context/LicenseContext'; -import { License } from '../../../../../../../../plugins/licensing/common/license'; +import { License } from '../../../../../../licensing/common/license'; import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; import * as apmApi from '../../../../services/rest/createCallApmApi'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts similarity index 98% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts index 3032dd1704f4e..b2f6f39e0b596 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts @@ -5,7 +5,7 @@ */ import { Location } from 'history'; import { getSections } from '../sections'; -import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import { AppMountContextBasePath } from '../../../../context/ApmPluginContext'; describe('Transaction action menu', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts similarity index 98% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts rename to x-pack/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts index ffdf0b485da64..2c2f4bfcadd7d 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts @@ -8,7 +8,7 @@ import { Location } from 'history'; import { pick, isEmpty } from 'lodash'; import moment from 'moment'; import url from 'url'; -import { Transaction } from '../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import { getDiscoverHref } from '../Links/DiscoverLinks/DiscoverLink'; import { getDiscoverQuery } from '../Links/DiscoverLinks/DiscoverTransactionLink'; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx new file mode 100644 index 0000000000000..966cc64fde505 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx @@ -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 React, { useMemo } from 'react'; +import numeral from '@elastic/numeral'; +import { throttle } from 'lodash'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; +import { Maybe } from '../../../../../typings/common'; +import { TransactionLineChart } from '../../charts/TransactionCharts/TransactionLineChart'; +import { asPercent } from '../../../../utils/formatters'; +import { unit } from '../../../../style/variables'; +import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; +import { useUiTracker } from '../../../../../../observability/public'; + +interface Props { + timeseries: TimeSeries[]; +} + +const tickFormatY = (y: Maybe<number>) => { + return numeral(y || 0).format('0 %'); +}; + +const formatTooltipValue = (coordinate: Coordinate) => { + return isValidCoordinateValue(coordinate.y) + ? asPercent(coordinate.y, 1) + : NOT_AVAILABLE_LABEL; +}; + +const TransactionBreakdownGraph: React.FC<Props> = props => { + const { timeseries } = props; + const trackApmEvent = useUiTracker({ app: 'apm' }); + const handleHover = useMemo( + () => + throttle(() => trackApmEvent({ metric: 'hover_breakdown_chart' }), 60000), + [trackApmEvent] + ); + + return ( + <TransactionLineChart + series={timeseries} + tickFormatY={tickFormatY} + formatTooltipValue={formatTooltipValue} + yMax={1} + height={unit * 12} + stacked={true} + onHover={handleHover} + /> + ); +}; + +export { TransactionBreakdownGraph }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx similarity index 95% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx index 91f5f4e0a7176..c4a8e07fb3004 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownKpiList.tsx @@ -13,10 +13,7 @@ import { EuiIcon } from '@elastic/eui'; import styled from 'styled-components'; -import { - FORMATTERS, - InfraFormatterType -} from '../../../../../../../plugins/infra/public'; +import { FORMATTERS, InfraFormatterType } from '../../../../../infra/public'; interface TransactionBreakdownKpi { name: string; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx new file mode 100644 index 0000000000000..be5860190c11e --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.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. + */ +import React, { useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown'; +import { TransactionBreakdownHeader } from './TransactionBreakdownHeader'; +import { TransactionBreakdownKpiList } from './TransactionBreakdownKpiList'; +import { TransactionBreakdownGraph } from './TransactionBreakdownGraph'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; +import { useUiTracker } from '../../../../../observability/public'; + +const emptyMessage = i18n.translate('xpack.apm.transactionBreakdown.noData', { + defaultMessage: 'No data within this time range.' +}); + +const TransactionBreakdown: React.FC<{ + initialIsOpen?: boolean; +}> = ({ initialIsOpen }) => { + const [showChart, setShowChart] = useState(!!initialIsOpen); + const { data, status } = useTransactionBreakdown(); + const trackApmEvent = useUiTracker({ app: 'apm' }); + const { kpis, timeseries } = data; + const noHits = data.kpis.length === 0 && status === FETCH_STATUS.SUCCESS; + const showEmptyMessage = noHits && !showChart; + + return ( + <EuiPanel> + <EuiFlexGroup direction="column" gutterSize="s"> + <EuiFlexItem grow={false}> + <TransactionBreakdownHeader + showChart={showChart} + onToggleClick={() => { + setShowChart(!showChart); + if (showChart) { + trackApmEvent({ metric: 'hide_breakdown_chart' }); + } else { + trackApmEvent({ metric: 'show_breakdown_chart' }); + } + }} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + {showEmptyMessage ? ( + <EuiText>{emptyMessage}</EuiText> + ) : ( + <TransactionBreakdownKpiList kpis={kpis} /> + )} + </EuiFlexItem> + {showChart ? ( + <EuiFlexItem grow={false}> + <TransactionBreakdownGraph timeseries={timeseries} /> + </EuiFlexItem> + ) : null} + </EuiFlexGroup> + </EuiPanel> + ); +}; + +export { TransactionBreakdown }; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx rename to x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx new file mode 100644 index 0000000000000..1e9fbd2c1c135 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx @@ -0,0 +1,149 @@ +/* + * 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 { map } from 'lodash'; +import { EuiFieldNumber, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; +import { + TRANSACTION_ALERT_AGGREGATION_TYPES, + ALERT_TYPES_CONFIG +} from '../../../../common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +interface Params { + windowSize: number; + windowUnit: string; + threshold: number; + aggregationType: 'avg' | '95th' | '99th'; + serviceName: string; + transactionType: string; +} + +interface Props { + alertParams: Params; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function TransactionDurationAlertTrigger(props: Props) { + const { setAlertParams, alertParams, setAlertProperty } = props; + + const { urlParams } = useUrlParams(); + + const transactionTypes = useServiceTransactionTypes(urlParams); + + if (!transactionTypes.length) { + return null; + } + + const defaults = { + threshold: 1500, + aggregationType: 'avg', + windowSize: 5, + windowUnit: 'm', + transactionType: transactionTypes[0] + }; + + const params = { + ...defaults, + ...alertParams + }; + + const fields = [ + <PopoverExpression + value={params.transactionType} + title={i18n.translate('xpack.apm.transactionDurationAlertTrigger.type', { + defaultMessage: 'Type' + })} + > + <EuiSelect + value={params.transactionType} + options={transactionTypes.map(key => { + return { + text: key, + value: key + }; + })} + onChange={e => + setAlertParams( + 'transactionType', + e.target.value as Params['transactionType'] + ) + } + compressed + /> + </PopoverExpression>, + <PopoverExpression + value={params.aggregationType} + title={i18n.translate('xpack.apm.transactionDurationAlertTrigger.when', { + defaultMessage: 'When' + })} + > + <EuiSelect + value={params.aggregationType} + options={map(TRANSACTION_ALERT_AGGREGATION_TYPES, (label, key) => { + return { + text: label, + value: key + }; + })} + onChange={e => + setAlertParams( + 'aggregationType', + e.target.value as Params['aggregationType'] + ) + } + compressed + /> + </PopoverExpression>, + <PopoverExpression + value={params.threshold ? `${params.threshold}ms` : ''} + title={i18n.translate( + 'xpack.apm.transactionDurationAlertTrigger.isAbove', + { + defaultMessage: 'is above' + } + )} + > + <EuiFieldNumber + value={params.threshold ?? ''} + onChange={e => setAlertParams('threshold', e.target.value)} + append={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { + defaultMessage: 'ms' + })} + compressed + /> + </PopoverExpression>, + <ForLastExpression + onChangeWindowSize={timeWindowSize => + setAlertParams('windowSize', timeWindowSize || '') + } + onChangeWindowUnit={timeWindowUnit => + setAlertParams('windowUnit', timeWindowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + <ServiceAlertTrigger + alertTypeName={ALERT_TYPES_CONFIG['apm.transaction_duration'].name} + fields={fields} + defaults={defaults} + setAlertParams={setAlertParams} + setAlertProperty={setAlertProperty} + /> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx similarity index 92% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx index ec6168df5b134..6eff4759b2e7c 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/AnnotationsPlot.tsx @@ -14,8 +14,8 @@ import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Maybe } from '../../../../../../../../plugins/apm/typings/common'; -import { Annotation } from '../../../../../../../../plugins/apm/common/annotations'; +import { Maybe } from '../../../../../typings/common'; +import { Annotation } from '../../../../../common/annotations'; import { PlotValues, SharedPlot } from './plotUtils'; import { asAbsoluteDateTime } from '../../../../utils/formatters'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/Legends.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/SelectionMarker.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/SelectionMarker.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/SelectionMarker.js rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/SelectionMarker.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StaticPlot.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/StatusText.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StatusText.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/StatusText.js rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/StatusText.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/VoronoiPlot.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/VoronoiPlot.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/VoronoiPlot.js rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/VoronoiPlot.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getEmptySeries.ts b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getEmptySeries.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getEmptySeries.ts rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getEmptySeries.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.test.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.ts b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.ts rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/getTimezoneOffsetInMs.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/index.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/index.js rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/index.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.test.ts b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.test.ts similarity index 94% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.test.ts rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.test.ts index bfc5c7c243f31..b130deed7f098 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.test.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.test.ts @@ -6,10 +6,7 @@ // @ts-ignore import * as plotUtils from './plotUtils'; -import { - TimeSeries, - Coordinate -} from '../../../../../../../../plugins/apm/typings/timeseries'; +import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; describe('plotUtils', () => { describe('getPlotValues', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.tsx b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.tsx rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.tsx index c489c270d19ac..64350a5741647 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/plotUtils.tsx @@ -11,10 +11,7 @@ import d3 from 'd3'; import PropTypes from 'prop-types'; import React from 'react'; -import { - TimeSeries, - Coordinate -} from '../../../../../../../../plugins/apm/typings/timeseries'; +import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; import { unit } from '../../../../style/variables'; import { getDomainTZ, getTimeTicksTZ } from '../helper/timezone'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/CustomPlot.test.js b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/CustomPlot.test.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/CustomPlot.test.js rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/CustomPlot.test.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/__snapshots__/CustomPlot.test.js.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json b/x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json rename to x-pack/plugins/apm/public/components/shared/charts/CustomPlot/test/responseWithData.json diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js rename to x-pack/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js rename to x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap rename to x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json rename to x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/index.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Histogram/index.js rename to x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Legend/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/Legend/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Legend/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Legend/index.tsx diff --git a/x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx new file mode 100644 index 0000000000000..862f2a8987067 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/MetricsChart/index.tsx @@ -0,0 +1,105 @@ +/* + * 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 { EuiTitle } from '@elastic/eui'; +import React from 'react'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { GenericMetricsChart } from '../../../../../server/lib/metrics/transform_metrics_chart'; +// @ts-ignore +import CustomPlot from '../CustomPlot'; +import { + asDecimal, + asPercent, + asInteger, + asDynamicBytes, + getFixedByteFormatter, + asDuration +} from '../../../../utils/formatters'; +import { Coordinate } from '../../../../../typings/timeseries'; +import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; +import { useChartsSync } from '../../../../hooks/useChartsSync'; +import { Maybe } from '../../../../../typings/common'; + +interface Props { + start: Maybe<number | string>; + end: Maybe<number | string>; + chart: GenericMetricsChart; +} + +export function MetricsChart({ chart }: Props) { + const formatYValue = getYTickFormatter(chart); + const formatTooltip = getTooltipFormatter(chart); + + const transformedSeries = chart.series.map(series => ({ + ...series, + legendValue: formatYValue(series.overallValue) + })); + + const syncedChartProps = useChartsSync(); + + return ( + <React.Fragment> + <EuiTitle size="xs"> + <span>{chart.title}</span> + </EuiTitle> + <CustomPlot + {...syncedChartProps} + series={transformedSeries} + tickFormatY={formatYValue} + formatTooltipValue={formatTooltip} + yMax={chart.yUnit === 'percent' ? 1 : 'max'} + /> + </React.Fragment> + ); +} + +function getYTickFormatter(chart: GenericMetricsChart) { + switch (chart.yUnit) { + case 'bytes': { + const max = Math.max( + ...chart.series.map(({ data }) => + Math.max(...data.map(({ y }) => y || 0)) + ) + ); + return getFixedByteFormatter(max); + } + case 'percent': { + return (y: Maybe<number>) => asPercent(y || 0, 1); + } + case 'time': { + return (y: Maybe<number>) => asDuration(y); + } + case 'integer': { + return (y: Maybe<number>) => + isValidCoordinateValue(y) ? asInteger(y) : y; + } + default: { + return (y: Maybe<number>) => + isValidCoordinateValue(y) ? asDecimal(y) : y; + } + } +} + +function getTooltipFormatter({ yUnit }: GenericMetricsChart) { + switch (yUnit) { + case 'bytes': { + return (c: Coordinate) => asDynamicBytes(c.y); + } + case 'percent': { + return (c: Coordinate) => asPercent(c.y || 0, 1); + } + case 'time': { + return (c: Coordinate) => asDuration(c.y); + } + case 'integer': { + return (c: Coordinate) => + isValidCoordinateValue(c.y) ? asInteger(c.y) : c.y; + } + default: { + return (c: Coordinate) => + isValidCoordinateValue(c.y) ? asDecimal(c.y) : c.y; + } + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/LastTickValue.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/LastTickValue.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/LastTickValue.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/LastTickValue.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/AgentMarker.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx similarity index 97% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx index 48265ce7c80a8..51368a4fb946d 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { TRACE_ID, TRANSACTION_ID -} from '../../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +} from '../../../../../../common/elasticsearch_fieldnames'; import { useUrlParams } from '../../../../../hooks/useUrlParams'; import { px, unit, units } from '../../../../../style/variables'; import { asDuration } from '../../../../../utils/formatters'; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/AgentMarker.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/AgentMarker.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/AgentMarker.test.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/AgentMarker.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/ErrorMarker.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/ErrorMarker.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/ErrorMarker.test.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/ErrorMarker.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/Marker.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/Marker.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/Marker.test.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/Marker.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/AgentMarker.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/AgentMarker.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/AgentMarker.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/AgentMarker.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/ErrorMarker.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/ErrorMarker.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/ErrorMarker.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/ErrorMarker.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/Marker.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/Marker.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/Marker.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/__test__/__snapshots__/Marker.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Timeline.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Timeline.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/Timeline.test.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/Timeline.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/TimelineAxis.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/VerticalLines.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/__snapshots__/Timeline.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/charts/Timeline/__snapshots__/Timeline.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/__snapshots__/Timeline.test.tsx.snap rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/__snapshots__/Timeline.test.tsx.snap diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/plotUtils.ts b/x-pack/plugins/apm/public/components/shared/charts/Timeline/plotUtils.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Timeline/plotUtils.ts rename to x-pack/plugins/apm/public/components/shared/charts/Timeline/plotUtils.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/Tooltip/index.js b/x-pack/plugins/apm/public/components/shared/charts/Tooltip/index.js similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/Tooltip/index.js rename to x-pack/plugins/apm/public/components/shared/charts/Tooltip/index.js diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx rename to x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx rename to x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx rename to x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx new file mode 100644 index 0000000000000..27c829f63cf0a --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx @@ -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 React, { useCallback } from 'react'; +import { + Coordinate, + RectCoordinate +} from '../../../../../../typings/timeseries'; +import { useChartsSync } from '../../../../../hooks/useChartsSync'; +// @ts-ignore +import CustomPlot from '../../CustomPlot'; + +interface Props { + series: Array<{ + color: string; + title: React.ReactNode; + titleShort?: React.ReactNode; + data: Array<Coordinate | RectCoordinate>; + type: string; + }>; + truncateLegends?: boolean; + tickFormatY: (y: number) => React.ReactNode; + formatTooltipValue: (c: Coordinate) => React.ReactNode; + yMax?: string | number; + height?: number; + stacked?: boolean; + onHover?: () => void; +} + +const TransactionLineChart: React.FC<Props> = (props: Props) => { + const { + series, + tickFormatY, + formatTooltipValue, + yMax = 'max', + height, + truncateLegends, + stacked = false, + onHover + } = props; + + const syncedChartsProps = useChartsSync(); + + // combine callback for syncedChartsProps.onHover and props.onHover + const combinedOnHover = useCallback( + (hoverX: number) => { + if (onHover) { + onHover(); + } + return syncedChartsProps.onHover(hoverX); + }, + [syncedChartsProps, onHover] + ); + + return ( + <CustomPlot + series={series} + {...syncedChartsProps} + onHover={combinedOnHover} + tickFormatY={tickFormatY} + formatTooltipValue={formatTooltipValue} + yMax={yMax} + height={height} + truncateLegends={truncateLegends} + {...(stacked ? { stackBy: 'y' } : {})} + /> + ); +}; + +export { TransactionLineChart }; diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx new file mode 100644 index 0000000000000..b0555da705a30 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -0,0 +1,266 @@ +/* + * 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 { + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiPanel, + EuiText, + EuiTitle, + EuiSpacer +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Location } from 'history'; +import React, { Component } from 'react'; +import { isEmpty, flatten } from 'lodash'; +import styled from 'styled-components'; +import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; +import { ITransactionChartData } from '../../../../selectors/chartSelectors'; +import { IUrlParams } from '../../../../context/UrlParamsContext/types'; +import { + asInteger, + tpmUnit, + TimeFormatter, + getDurationFormatter +} from '../../../../utils/formatters'; +import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink'; +import { LicenseContext } from '../../../../context/LicenseContext'; +import { TransactionLineChart } from './TransactionLineChart'; +import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; +import { BrowserLineChart } from './BrowserLineChart'; +import { DurationByCountryMap } from './DurationByCountryMap'; +import { + TRANSACTION_PAGE_LOAD, + TRANSACTION_ROUTE_CHANGE, + TRANSACTION_REQUEST +} from '../../../../../common/transaction_types'; + +interface TransactionChartProps { + hasMLJob: boolean; + charts: ITransactionChartData; + location: Location; + urlParams: IUrlParams; +} + +const ShiftedIconWrapper = styled.span` + padding-right: 5px; + position: relative; + top: -1px; + display: inline-block; +`; + +const ShiftedEuiText = styled(EuiText)` + position: relative; + top: 5px; +`; + +export function getResponseTimeTickFormatter(formatter: TimeFormatter) { + return (t: number) => formatter(t).formatted; +} + +export function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { + return (p: Coordinate) => { + return isValidCoordinateValue(p.y) + ? formatter(p.y).formatted + : NOT_AVAILABLE_LABEL; + }; +} + +export function getMaxY(responseTimeSeries: TimeSeries[]) { + const coordinates = flatten( + responseTimeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) + ); + + const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); + + return Math.max(...numbers, 0); +} + +export class TransactionCharts extends Component<TransactionChartProps> { + public getTPMFormatter = (t: number) => { + const { urlParams } = this.props; + const unit = tpmUnit(urlParams.transactionType); + return `${asInteger(t)} ${unit}`; + }; + + public getTPMTooltipFormatter = (p: Coordinate) => { + return isValidCoordinateValue(p.y) + ? this.getTPMFormatter(p.y) + : NOT_AVAILABLE_LABEL; + }; + + public renderMLHeader(hasValidMlLicense: boolean | undefined) { + const { hasMLJob } = this.props; + if (!hasValidMlLicense || !hasMLJob) { + return null; + } + + const { serviceName, transactionType, kuery } = this.props.urlParams; + if (!serviceName) { + return null; + } + + const hasKuery = !isEmpty(kuery); + const icon = hasKuery ? ( + <EuiIconTip + aria-label="Warning" + type="alert" + color="warning" + content="The Machine learning results are hidden when the search bar is used for filtering" + /> + ) : ( + <EuiIconTip + content={i18n.translate( + 'xpack.apm.metrics.transactionChart.machineLearningTooltip', + { + defaultMessage: + 'The stream around the average duration shows the expected bounds. An annotation is shown for anomaly scores >= 75.' + } + )} + /> + ); + + return ( + <EuiFlexItem grow={false}> + <ShiftedEuiText size="xs"> + <ShiftedIconWrapper>{icon}</ShiftedIconWrapper> + <span> + {i18n.translate( + 'xpack.apm.metrics.transactionChart.machineLearningLabel', + { + defaultMessage: 'Machine learning:' + } + )}{' '} + </span> + <MLJobLink + serviceName={serviceName} + transactionType={transactionType} + > + View Job + </MLJobLink> + </ShiftedEuiText> + </EuiFlexItem> + ); + } + + public render() { + const { charts, urlParams } = this.props; + const { responseTimeSeries, tpmSeries } = charts; + const { transactionType } = urlParams; + const maxY = getMaxY(responseTimeSeries); + const formatter = getDurationFormatter(maxY); + + return ( + <> + <EuiFlexGrid columns={2} gutterSize="s"> + <EuiFlexItem data-cy={`transaction-duration-charts`}> + <EuiPanel> + <React.Fragment> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem> + <EuiTitle size="xs"> + <span>{responseTimeLabel(transactionType)}</span> + </EuiTitle> + </EuiFlexItem> + <LicenseContext.Consumer> + {license => + this.renderMLHeader(license?.getFeature('ml').isAvailable) + } + </LicenseContext.Consumer> + </EuiFlexGroup> + <TransactionLineChart + series={responseTimeSeries} + tickFormatY={getResponseTimeTickFormatter(formatter)} + formatTooltipValue={getResponseTimeTooltipFormatter( + formatter + )} + /> + </React.Fragment> + </EuiPanel> + </EuiFlexItem> + + <EuiFlexItem style={{ flexShrink: 1 }}> + <EuiPanel> + <React.Fragment> + <EuiTitle size="xs"> + <span>{tpmLabel(transactionType)}</span> + </EuiTitle> + <TransactionLineChart + series={tpmSeries} + tickFormatY={this.getTPMFormatter} + formatTooltipValue={this.getTPMTooltipFormatter} + truncateLegends + /> + </React.Fragment> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGrid> + {transactionType === TRANSACTION_PAGE_LOAD && ( + <> + <EuiSpacer size="s" /> + <EuiFlexGrid columns={2} gutterSize="s"> + <EuiFlexItem> + <EuiPanel> + <DurationByCountryMap /> + </EuiPanel> + </EuiFlexItem> + <EuiFlexItem> + <EuiPanel> + <BrowserLineChart /> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGrid> + </> + )} + </> + ); + } +} + +function tpmLabel(type?: string) { + return type === TRANSACTION_REQUEST + ? i18n.translate( + 'xpack.apm.metrics.transactionChart.requestsPerMinuteLabel', + { + defaultMessage: 'Requests per minute' + } + ) + : i18n.translate( + 'xpack.apm.metrics.transactionChart.transactionsPerMinuteLabel', + { + defaultMessage: 'Transactions per minute' + } + ); +} + +function responseTimeLabel(type?: string) { + switch (type) { + case TRANSACTION_PAGE_LOAD: + return i18n.translate( + 'xpack.apm.metrics.transactionChart.pageLoadTimesLabel', + { + defaultMessage: 'Page load times' + } + ); + case TRANSACTION_ROUTE_CHANGE: + return i18n.translate( + 'xpack.apm.metrics.transactionChart.routeChangeTimesLabel', + { + defaultMessage: 'Route change times' + } + ); + default: + return i18n.translate( + 'xpack.apm.metrics.transactionChart.transactionDurationLabel', + { + defaultMessage: 'Transaction duration' + } + ); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/helper/__test__/timezone.test.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/__test__/timezone.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/helper/__test__/timezone.test.ts rename to x-pack/plugins/apm/public/components/shared/charts/helper/__test__/timezone.test.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/helper/timezone.ts b/x-pack/plugins/apm/public/components/shared/charts/helper/timezone.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/charts/helper/timezone.ts rename to x-pack/plugins/apm/public/components/shared/charts/helper/timezone.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/useDelayedVisibility/Delayed/index.test.tsx b/x-pack/plugins/apm/public/components/shared/useDelayedVisibility/Delayed/index.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/useDelayedVisibility/Delayed/index.test.tsx rename to x-pack/plugins/apm/public/components/shared/useDelayedVisibility/Delayed/index.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/useDelayedVisibility/Delayed/index.ts b/x-pack/plugins/apm/public/components/shared/useDelayedVisibility/Delayed/index.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/useDelayedVisibility/Delayed/index.ts rename to x-pack/plugins/apm/public/components/shared/useDelayedVisibility/Delayed/index.ts diff --git a/x-pack/legacy/plugins/apm/public/components/shared/useDelayedVisibility/index.test.tsx b/x-pack/plugins/apm/public/components/shared/useDelayedVisibility/index.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/useDelayedVisibility/index.test.tsx rename to x-pack/plugins/apm/public/components/shared/useDelayedVisibility/index.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/components/shared/useDelayedVisibility/index.ts b/x-pack/plugins/apm/public/components/shared/useDelayedVisibility/index.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/components/shared/useDelayedVisibility/index.ts rename to x-pack/plugins/apm/public/components/shared/useDelayedVisibility/index.ts diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx similarity index 93% rename from x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx rename to x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx index cc2e382611628..865e3dbe6dafc 100644 --- a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx +++ b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { ApmPluginContext, ApmPluginContextValue } from '.'; import { createCallApmApi } from '../../services/rest/createCallApmApi'; -import { ConfigSchema } from '../../new-platform/plugin'; +import { ConfigSchema } from '../..'; const mockCore = { chrome: { @@ -30,7 +30,6 @@ const mockCore = { }; const mockConfig: ConfigSchema = { - indexPatternTitle: 'apm-*', serviceMapEnabled: true, ui: { enabled: false diff --git a/x-pack/plugins/apm/public/context/ApmPluginContext/index.tsx b/x-pack/plugins/apm/public/context/ApmPluginContext/index.tsx new file mode 100644 index 0000000000000..37304d292540d --- /dev/null +++ b/x-pack/plugins/apm/public/context/ApmPluginContext/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createContext } from 'react'; +import { AppMountContext } from 'kibana/public'; +import { ConfigSchema } from '../..'; +import { ApmPluginSetupDeps } from '../../plugin'; + +export type AppMountContextBasePath = AppMountContext['core']['http']['basePath']; + +export interface ApmPluginContextValue { + config: ConfigSchema; + core: AppMountContext['core']; + plugins: ApmPluginSetupDeps; +} + +export const ApmPluginContext = createContext({} as ApmPluginContextValue); diff --git a/x-pack/legacy/plugins/apm/public/context/ChartsSyncContext.tsx b/x-pack/plugins/apm/public/context/ChartsSyncContext.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/context/ChartsSyncContext.tsx rename to x-pack/plugins/apm/public/context/ChartsSyncContext.tsx diff --git a/x-pack/legacy/plugins/apm/public/context/LicenseContext/InvalidLicenseNotification.tsx b/x-pack/plugins/apm/public/context/LicenseContext/InvalidLicenseNotification.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/context/LicenseContext/InvalidLicenseNotification.tsx rename to x-pack/plugins/apm/public/context/LicenseContext/InvalidLicenseNotification.tsx diff --git a/x-pack/plugins/apm/public/context/LicenseContext/index.tsx b/x-pack/plugins/apm/public/context/LicenseContext/index.tsx new file mode 100644 index 0000000000000..e6615a2fc98bf --- /dev/null +++ b/x-pack/plugins/apm/public/context/LicenseContext/index.tsx @@ -0,0 +1,30 @@ +/* + * 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 useObservable from 'react-use/lib/useObservable'; +import { ILicense } from '../../../../licensing/public'; +import { useApmPluginContext } from '../../hooks/useApmPluginContext'; +import { InvalidLicenseNotification } from './InvalidLicenseNotification'; + +export const LicenseContext = React.createContext<ILicense | undefined>( + undefined +); + +export function LicenseProvider({ children }: { children: React.ReactChild }) { + const { license$ } = useApmPluginContext().plugins.licensing; + const license = useObservable(license$); + // if license is not loaded yet, consider it valid + const hasInvalidLicense = license?.isActive === false; + + // if license is invalid show an error message + if (hasInvalidLicense) { + return <InvalidLicenseNotification />; + } + + // render rest of application and pass down license via context + return <LicenseContext.Provider value={license} children={children} />; +} diff --git a/x-pack/legacy/plugins/apm/public/context/LoadingIndicatorContext.tsx b/x-pack/plugins/apm/public/context/LoadingIndicatorContext.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/context/LoadingIndicatorContext.tsx rename to x-pack/plugins/apm/public/context/LoadingIndicatorContext.tsx diff --git a/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx b/x-pack/plugins/apm/public/context/LocationContext.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/context/LocationContext.tsx rename to x-pack/plugins/apm/public/context/LocationContext.tsx diff --git a/x-pack/legacy/plugins/apm/public/context/MatchedRouteContext.tsx b/x-pack/plugins/apm/public/context/MatchedRouteContext.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/context/MatchedRouteContext.tsx rename to x-pack/plugins/apm/public/context/MatchedRouteContext.tsx diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx b/x-pack/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx rename to x-pack/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx b/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx rename to x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/constants.ts b/x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/context/UrlParamsContext/constants.ts rename to x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/helpers.ts b/x-pack/plugins/apm/public/context/UrlParamsContext/helpers.ts new file mode 100644 index 0000000000000..f1e45fe45255d --- /dev/null +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/helpers.ts @@ -0,0 +1,131 @@ +/* + * 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 { compact, pick } from 'lodash'; +import datemath from '@elastic/datemath'; +import { IUrlParams } from './types'; +import { ProcessorEvent } from '../../../common/processor_event'; + +interface PathParams { + processorEvent?: ProcessorEvent; + serviceName?: string; + errorGroupId?: string; + serviceNodeName?: string; + traceId?: string; +} + +export function getParsedDate(rawDate?: string, opts = {}) { + if (rawDate) { + const parsed = datemath.parse(rawDate, opts); + if (parsed) { + return parsed.toISOString(); + } + } +} + +export function getStart(prevState: IUrlParams, rangeFrom?: string) { + if (prevState.rangeFrom !== rangeFrom) { + return getParsedDate(rangeFrom); + } + return prevState.start; +} + +export function getEnd(prevState: IUrlParams, rangeTo?: string) { + if (prevState.rangeTo !== rangeTo) { + return getParsedDate(rangeTo, { roundUp: true }); + } + return prevState.end; +} + +export function toNumber(value?: string) { + if (value !== undefined) { + return parseInt(value, 10); + } +} + +export function toString(value?: string) { + if (value === '' || value === 'null' || value === 'undefined') { + return; + } + return value; +} + +export function toBoolean(value?: string) { + return value === 'true'; +} + +export function getPathAsArray(pathname: string = '') { + return compact(pathname.split('/')); +} + +export function removeUndefinedProps<T>(obj: T): Partial<T> { + return pick(obj, value => value !== undefined); +} + +export function getPathParams(pathname: string = ''): PathParams { + const paths = getPathAsArray(pathname); + const pageName = paths[0]; + // TODO: use react router's real match params instead of guessing the path order + + switch (pageName) { + case 'services': + let servicePageName = paths[2]; + const serviceName = paths[1]; + const serviceNodeName = paths[3]; + + if (servicePageName === 'nodes' && paths.length > 3) { + servicePageName = 'metrics'; + } + + switch (servicePageName) { + case 'transactions': + return { + processorEvent: ProcessorEvent.transaction, + serviceName + }; + case 'errors': + return { + processorEvent: ProcessorEvent.error, + serviceName, + errorGroupId: paths[3] + }; + case 'metrics': + return { + processorEvent: ProcessorEvent.metric, + serviceName, + serviceNodeName + }; + case 'nodes': + return { + processorEvent: ProcessorEvent.metric, + serviceName + }; + case 'service-map': + return { + serviceName + }; + default: + return {}; + } + + case 'traces': + return { + processorEvent: ProcessorEvent.transaction + }; + case 'link-to': + const link = paths[1]; + switch (link) { + case 'trace': + return { + traceId: paths[2] + }; + default: + return {}; + } + default: + return {}; + } +} diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/index.tsx b/x-pack/plugins/apm/public/context/UrlParamsContext/index.tsx new file mode 100644 index 0000000000000..7a929380bce37 --- /dev/null +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/index.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, { + createContext, + useMemo, + useCallback, + useRef, + useState +} from 'react'; +import { withRouter } from 'react-router-dom'; +import { uniqueId, mapValues } from 'lodash'; +import { IUrlParams } from './types'; +import { getParsedDate } from './helpers'; +import { resolveUrlParams } from './resolveUrlParams'; +import { UIFilters } from '../../../typings/ui_filters'; +import { + localUIFilterNames, + LocalUIFilterName + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../server/lib/ui_filters/local_ui_filters/config'; +import { pickKeys } from '../../../common/utils/pick_keys'; +import { useDeepObjectIdentity } from '../../hooks/useDeepObjectIdentity'; + +interface TimeRange { + rangeFrom: string; + rangeTo: string; +} + +function useUiFilters(params: IUrlParams): UIFilters { + const { kuery, environment, ...urlParams } = params; + const localUiFilters = mapValues( + pickKeys(urlParams, ...localUIFilterNames), + val => (val ? val.split(',') : []) + ) as Partial<Record<LocalUIFilterName, string[]>>; + + return useDeepObjectIdentity({ kuery, environment, ...localUiFilters }); +} + +const defaultRefresh = (time: TimeRange) => {}; + +const UrlParamsContext = createContext({ + urlParams: {} as IUrlParams, + refreshTimeRange: defaultRefresh, + uiFilters: {} as UIFilters +}); + +const UrlParamsProvider: React.ComponentClass<{}> = withRouter( + ({ location, children }) => { + const refUrlParams = useRef(resolveUrlParams(location, {})); + + const { start, end, rangeFrom, rangeTo } = refUrlParams.current; + + const [, forceUpdate] = useState(''); + + const urlParams = useMemo( + () => + resolveUrlParams(location, { + start, + end, + rangeFrom, + rangeTo + }), + [location, start, end, rangeFrom, rangeTo] + ); + + refUrlParams.current = urlParams; + + const refreshTimeRange = useCallback( + (timeRange: TimeRange) => { + refUrlParams.current = { + ...refUrlParams.current, + start: getParsedDate(timeRange.rangeFrom), + end: getParsedDate(timeRange.rangeTo, { roundUp: true }) + }; + + forceUpdate(uniqueId()); + }, + [forceUpdate] + ); + + const uiFilters = useUiFilters(urlParams); + + const contextValue = useMemo(() => { + return { + urlParams, + refreshTimeRange, + uiFilters + }; + }, [urlParams, refreshTimeRange, uiFilters]); + + return ( + <UrlParamsContext.Provider children={children} value={contextValue} /> + ); + } +); + +export { UrlParamsContext, UrlParamsProvider, useUiFilters }; diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts b/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts similarity index 93% rename from x-pack/legacy/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts rename to x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts index f022d2084583b..34af18431a2df 100644 --- a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts @@ -18,8 +18,8 @@ import { import { toQuery } from '../../components/shared/Links/url_helpers'; import { TIMEPICKER_DEFAULTS } from './constants'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { localUIFilterNames } from '../../../../../../plugins/apm/server/lib/ui_filters/local_ui_filters/config'; -import { pickKeys } from '../../utils/pickKeys'; +import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; +import { pickKeys } from '../../../common/utils/pick_keys'; type TimeUrlParams = Pick< IUrlParams, diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/types.ts b/x-pack/plugins/apm/public/context/UrlParamsContext/types.ts new file mode 100644 index 0000000000000..78fe662b88d75 --- /dev/null +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/types.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { LocalUIFilterName } from '../../../server/lib/ui_filters/local_ui_filters/config'; +import { ProcessorEvent } from '../../../common/processor_event'; + +export type IUrlParams = { + detailTab?: string; + end?: string; + errorGroupId?: string; + flyoutDetailTab?: string; + kuery?: string; + environment?: string; + rangeFrom?: string; + rangeTo?: string; + refreshInterval?: number; + refreshPaused?: boolean; + serviceName?: string; + sortDirection?: string; + sortField?: string; + start?: string; + traceId?: string; + transactionId?: string; + transactionName?: string; + transactionType?: string; + waterfallItemId?: string; + page?: number; + pageSize?: number; + serviceNodeName?: string; + searchTerm?: string; + processorEvent?: ProcessorEvent; + traceIdLink?: string; +} & Partial<Record<LocalUIFilterName, string>>; diff --git a/x-pack/legacy/plugins/apm/public/new-platform/featureCatalogueEntry.ts b/x-pack/plugins/apm/public/featureCatalogueEntry.ts similarity index 88% rename from x-pack/legacy/plugins/apm/public/new-platform/featureCatalogueEntry.ts rename to x-pack/plugins/apm/public/featureCatalogueEntry.ts index 7a150de6d5d02..f76c6f5169dc5 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/featureCatalogueEntry.ts +++ b/x-pack/plugins/apm/public/featureCatalogueEntry.ts @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import { FeatureCatalogueCategory } from '../../../../../../src/plugins/home/public'; +import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; export const featureCatalogueEntry = { id: 'apm', diff --git a/x-pack/legacy/plugins/apm/public/hooks/useAgentName.ts b/x-pack/plugins/apm/public/hooks/useAgentName.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useAgentName.ts rename to x-pack/plugins/apm/public/hooks/useAgentName.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useApmPluginContext.ts b/x-pack/plugins/apm/public/hooks/useApmPluginContext.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useApmPluginContext.ts rename to x-pack/plugins/apm/public/hooks/useApmPluginContext.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useAvgDurationByBrowser.test.ts b/x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useAvgDurationByBrowser.test.ts rename to x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.test.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useAvgDurationByBrowser.ts b/x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.ts similarity index 83% rename from x-pack/legacy/plugins/apm/public/hooks/useAvgDurationByBrowser.ts rename to x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.ts index 256c2fa68bfbc..5d0c9d1435798 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useAvgDurationByBrowser.ts +++ b/x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.ts @@ -8,9 +8,9 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { useFetcher } from './useFetcher'; import { useUrlParams } from './useUrlParams'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AvgDurationByBrowserAPIResponse } from '../../../../../plugins/apm/server/lib/transactions/avg_duration_by_browser'; -import { TimeSeries } from '../../../../../plugins/apm/typings/timeseries'; -import { getVizColorForIndex } from '../../../../../plugins/apm/common/viz_colors'; +import { AvgDurationByBrowserAPIResponse } from '../../server/lib/transactions/avg_duration_by_browser'; +import { TimeSeries } from '../../typings/timeseries'; +import { getVizColorForIndex } from '../../common/viz_colors'; function toTimeSeries(data?: AvgDurationByBrowserAPIResponse): TimeSeries[] { if (!data) { diff --git a/x-pack/legacy/plugins/apm/public/hooks/useAvgDurationByCountry.ts b/x-pack/plugins/apm/public/hooks/useAvgDurationByCountry.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useAvgDurationByCountry.ts rename to x-pack/plugins/apm/public/hooks/useAvgDurationByCountry.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useCallApi.ts b/x-pack/plugins/apm/public/hooks/useCallApi.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useCallApi.ts rename to x-pack/plugins/apm/public/hooks/useCallApi.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useChartsSync.tsx b/x-pack/plugins/apm/public/hooks/useChartsSync.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useChartsSync.tsx rename to x-pack/plugins/apm/public/hooks/useChartsSync.tsx diff --git a/x-pack/legacy/plugins/apm/public/hooks/useComponentId.tsx b/x-pack/plugins/apm/public/hooks/useComponentId.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useComponentId.tsx rename to x-pack/plugins/apm/public/hooks/useComponentId.tsx diff --git a/x-pack/legacy/plugins/apm/public/hooks/useDeepObjectIdentity.ts b/x-pack/plugins/apm/public/hooks/useDeepObjectIdentity.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useDeepObjectIdentity.ts rename to x-pack/plugins/apm/public/hooks/useDeepObjectIdentity.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useDynamicIndexPattern.ts b/x-pack/plugins/apm/public/hooks/useDynamicIndexPattern.ts similarity index 89% rename from x-pack/legacy/plugins/apm/public/hooks/useDynamicIndexPattern.ts rename to x-pack/plugins/apm/public/hooks/useDynamicIndexPattern.ts index ee3d2e81f259f..9a95bd925d6e1 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useDynamicIndexPattern.ts +++ b/x-pack/plugins/apm/public/hooks/useDynamicIndexPattern.ts @@ -5,7 +5,7 @@ */ import { useFetcher } from './useFetcher'; -import { ProcessorEvent } from '../../../../../plugins/apm/common/processor_event'; +import { ProcessorEvent } from '../../common/processor_event'; export function useDynamicIndexPattern( processorEvent: ProcessorEvent | undefined diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx b/x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx rename to x-pack/plugins/apm/public/hooks/useFetcher.integration.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx b/x-pack/plugins/apm/public/hooks/useFetcher.test.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx rename to x-pack/plugins/apm/public/hooks/useFetcher.test.tsx diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/plugins/apm/public/hooks/useFetcher.tsx similarity index 98% rename from x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx rename to x-pack/plugins/apm/public/hooks/useFetcher.tsx index 95cebd6b2a465..5d5128d969aad 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/useFetcher.tsx @@ -9,7 +9,7 @@ import React, { useContext, useEffect, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { IHttpFetchError } from 'src/core/public'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; import { LoadingIndicatorContext } from '../context/LoadingIndicatorContext'; import { APMClient, callApmApi } from '../services/rest/createCallApmApi'; import { useApmPluginContext } from './useApmPluginContext'; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useKibanaUrl.ts b/x-pack/plugins/apm/public/hooks/useKibanaUrl.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useKibanaUrl.ts rename to x-pack/plugins/apm/public/hooks/useKibanaUrl.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useLicense.ts b/x-pack/plugins/apm/public/hooks/useLicense.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useLicense.ts rename to x-pack/plugins/apm/public/hooks/useLicense.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useLoadingIndicator.ts b/x-pack/plugins/apm/public/hooks/useLoadingIndicator.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useLoadingIndicator.ts rename to x-pack/plugins/apm/public/hooks/useLoadingIndicator.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useLocalUIFilters.ts b/x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts similarity index 88% rename from x-pack/legacy/plugins/apm/public/hooks/useLocalUIFilters.ts rename to x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts index 9f14b2b25fc94..1dfd3ec7c3ee3 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useLocalUIFilters.ts +++ b/x-pack/plugins/apm/public/hooks/useLocalUIFilters.ts @@ -7,18 +7,18 @@ import { omit } from 'lodash'; import { useFetcher } from './useFetcher'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { LocalUIFiltersAPIResponse } from '../../../../../plugins/apm/server/lib/ui_filters/local_ui_filters'; +import { LocalUIFiltersAPIResponse } from '../../server/lib/ui_filters/local_ui_filters'; import { useUrlParams } from './useUrlParams'; import { LocalUIFilterName, localUIFilters // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../plugins/apm/server/lib/ui_filters/local_ui_filters/config'; +} from '../../server/lib/ui_filters/local_ui_filters/config'; import { history } from '../utils/history'; import { toQuery, fromQuery } from '../components/shared/Links/url_helpers'; import { removeUndefinedProps } from '../context/UrlParamsContext/helpers'; -import { PROJECTION } from '../../../../../plugins/apm/common/projections/typings'; -import { pickKeys } from '../utils/pickKeys'; +import { PROJECTION } from '../../common/projections/typings'; +import { pickKeys } from '../../common/utils/pick_keys'; import { useCallApi } from './useCallApi'; const getInitialData = ( diff --git a/x-pack/legacy/plugins/apm/public/hooks/useLocation.tsx b/x-pack/plugins/apm/public/hooks/useLocation.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useLocation.tsx rename to x-pack/plugins/apm/public/hooks/useLocation.tsx diff --git a/x-pack/legacy/plugins/apm/public/hooks/useMatchedRoutes.tsx b/x-pack/plugins/apm/public/hooks/useMatchedRoutes.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useMatchedRoutes.tsx rename to x-pack/plugins/apm/public/hooks/useMatchedRoutes.tsx diff --git a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts b/x-pack/plugins/apm/public/hooks/useServiceMetricCharts.ts similarity index 91% rename from x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts rename to x-pack/plugins/apm/public/hooks/useServiceMetricCharts.ts index 72618a6254f4c..ebcd6ab063708 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts +++ b/x-pack/plugins/apm/public/hooks/useServiceMetricCharts.ts @@ -5,7 +5,7 @@ */ // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { MetricsChartsByAgentAPIResponse } from '../../../../../plugins/apm/server/lib/metrics/get_metrics_chart_data_by_agent'; +import { MetricsChartsByAgentAPIResponse } from '../../server/lib/metrics/get_metrics_chart_data_by_agent'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { useUiFilters } from '../context/UrlParamsContext'; import { useFetcher } from './useFetcher'; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useServiceTransactionTypes.tsx b/x-pack/plugins/apm/public/hooks/useServiceTransactionTypes.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useServiceTransactionTypes.tsx rename to x-pack/plugins/apm/public/hooks/useServiceTransactionTypes.tsx diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts b/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts rename to x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts b/x-pack/plugins/apm/public/hooks/useTransactionCharts.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts rename to x-pack/plugins/apm/public/hooks/useTransactionCharts.ts diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts b/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts similarity index 93% rename from x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts rename to x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts index 9a93a2334924a..152980b5655d6 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts @@ -8,7 +8,7 @@ import { IUrlParams } from '../context/UrlParamsContext/types'; import { useFetcher } from './useFetcher'; import { useUiFilters } from '../context/UrlParamsContext'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TransactionDistributionAPIResponse } from '../../../../../plugins/apm/server/lib/transactions/distribution'; +import { TransactionDistributionAPIResponse } from '../../server/lib/transactions/distribution'; const INITIAL_DATA = { buckets: [] as TransactionDistributionAPIResponse['buckets'], diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts b/x-pack/plugins/apm/public/hooks/useTransactionList.ts similarity index 94% rename from x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts rename to x-pack/plugins/apm/public/hooks/useTransactionList.ts index 6ede77023790b..e048e8fe0e3cb 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionList.ts @@ -9,7 +9,7 @@ import { IUrlParams } from '../context/UrlParamsContext/types'; import { useUiFilters } from '../context/UrlParamsContext'; import { useFetcher } from './useFetcher'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TransactionGroupListAPIResponse } from '../../../../../plugins/apm/server/lib/transaction_groups'; +import { TransactionGroupListAPIResponse } from '../../server/lib/transaction_groups'; const getRelativeImpact = ( impact: number, diff --git a/x-pack/legacy/plugins/apm/public/hooks/useUrlParams.tsx b/x-pack/plugins/apm/public/hooks/useUrlParams.tsx similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useUrlParams.tsx rename to x-pack/plugins/apm/public/hooks/useUrlParams.tsx diff --git a/x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts b/x-pack/plugins/apm/public/hooks/useWaterfall.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts rename to x-pack/plugins/apm/public/hooks/useWaterfall.ts diff --git a/x-pack/legacy/plugins/apm/public/icon.svg b/x-pack/plugins/apm/public/icon.svg similarity index 100% rename from x-pack/legacy/plugins/apm/public/icon.svg rename to x-pack/plugins/apm/public/icon.svg diff --git a/x-pack/legacy/plugins/apm/public/images/apm-ml-anomaly-detection-example.png b/x-pack/plugins/apm/public/images/apm-ml-anomaly-detection-example.png similarity index 100% rename from x-pack/legacy/plugins/apm/public/images/apm-ml-anomaly-detection-example.png rename to x-pack/plugins/apm/public/images/apm-ml-anomaly-detection-example.png diff --git a/x-pack/plugins/apm/public/index.ts b/x-pack/plugins/apm/public/index.ts new file mode 100644 index 0000000000000..4ac06e1eb8a1c --- /dev/null +++ b/x-pack/plugins/apm/public/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PluginInitializer, + PluginInitializerContext +} from '../../../../src/core/public'; +import { ApmPlugin, ApmPluginSetup, ApmPluginStart } from './plugin'; + +export interface ConfigSchema { + serviceMapEnabled: boolean; + ui: { + enabled: boolean; + }; +} + +export const plugin: PluginInitializer<ApmPluginSetup, ApmPluginStart> = ( + pluginInitializerContext: PluginInitializerContext<ConfigSchema> +) => new ApmPlugin(pluginInitializerContext); + +export { ApmPluginSetup, ApmPluginStart }; +export { getTraceUrl } from './components/shared/Links/apm/ExternalLinks'; diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts new file mode 100644 index 0000000000000..f13c8853d0582 --- /dev/null +++ b/x-pack/plugins/apm/public/plugin.ts @@ -0,0 +1,133 @@ +/* + * 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'; +import { + AppMountParameters, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext +} from '../../../../src/core/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; + +import { + PluginSetupContract as AlertingPluginPublicSetup, + PluginStartContract as AlertingPluginPublicStart +} from '../../alerting/public'; +import { FeaturesPluginSetup } from '../../features/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart +} from '../../../../src/plugins/data/public'; +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { LicensingPluginSetup } from '../../licensing/public'; +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart +} from '../../triggers_actions_ui/public'; +import { ConfigSchema } from '.'; +import { createCallApmApi } from './services/rest/createCallApmApi'; +import { featureCatalogueEntry } from './featureCatalogueEntry'; +import { AlertType } from '../common/alert_types'; +import { ErrorRateAlertTrigger } from './components/shared/ErrorRateAlertTrigger'; +import { TransactionDurationAlertTrigger } from './components/shared/TransactionDurationAlertTrigger'; +import { setHelpExtension } from './setHelpExtension'; +import { toggleAppLinkInNav } from './toggleAppLinkInNav'; +import { setReadonlyBadge } from './updateBadge'; +import { createStaticIndexPattern } from './services/rest/index_pattern'; + +export type ApmPluginSetup = void; +export type ApmPluginStart = void; + +export interface ApmPluginSetupDeps { + alerting?: AlertingPluginPublicSetup; + data: DataPublicPluginSetup; + features: FeaturesPluginSetup; + home: HomePublicPluginSetup; + licensing: LicensingPluginSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; +} + +export interface ApmPluginStartDeps { + alerting?: AlertingPluginPublicStart; + data: DataPublicPluginStart; + home: void; + licensing: void; + triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; +} + +export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> { + private readonly initializerContext: PluginInitializerContext<ConfigSchema>; + constructor(initializerContext: PluginInitializerContext<ConfigSchema>) { + this.initializerContext = initializerContext; + } + public setup(core: CoreSetup, plugins: ApmPluginSetupDeps) { + const config = this.initializerContext.config.get(); + const pluginSetupDeps = plugins; + + pluginSetupDeps.home.environment.update({ apmUi: true }); + pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry); + + core.application.register({ + id: 'apm', + title: 'APM', + order: 8100, + euiIconType: 'apmApp', + appRoute: '/app/apm', + icon: 'plugins/apm/public/icon.svg', + category: DEFAULT_APP_CATEGORIES.observability, + + async mount(params: AppMountParameters<unknown>) { + // Load application bundle + const { renderApp } = await import('./application'); + // Get start services + const [coreStart] = await core.getStartServices(); + + // render APM feedback link in global help menu + setHelpExtension(coreStart); + setReadonlyBadge(coreStart); + + // Automatically creates static index pattern and stores as saved object + createStaticIndexPattern().catch(e => { + // eslint-disable-next-line no-console + console.log('Error creating static index pattern', e); + }); + + return renderApp(coreStart, pluginSetupDeps, params, config); + } + }); + } + public start(core: CoreStart, plugins: ApmPluginStartDeps) { + createCallApmApi(core.http); + + toggleAppLinkInNav(core, this.initializerContext.config.get()); + + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.ErrorRate, + name: i18n.translate('xpack.apm.alertTypes.errorRate', { + defaultMessage: 'Error rate' + }), + iconClass: 'bell', + alertParamsExpression: ErrorRateAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.TransactionDuration, + name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { + defaultMessage: 'Transaction duration' + }), + iconClass: 'bell', + alertParamsExpression: TransactionDurationAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts b/x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts rename to x-pack/plugins/apm/public/selectors/__tests__/chartSelectors.test.ts diff --git a/x-pack/legacy/plugins/apm/public/selectors/__tests__/mockData/anomalyData.ts b/x-pack/plugins/apm/public/selectors/__tests__/mockData/anomalyData.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/selectors/__tests__/mockData/anomalyData.ts rename to x-pack/plugins/apm/public/selectors/__tests__/mockData/anomalyData.ts diff --git a/x-pack/legacy/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/selectors/chartSelectors.ts similarity index 94% rename from x-pack/legacy/plugins/apm/public/selectors/chartSelectors.ts rename to x-pack/plugins/apm/public/selectors/chartSelectors.ts index d60b63e243d71..e6ef9361ee52a 100644 --- a/x-pack/legacy/plugins/apm/public/selectors/chartSelectors.ts +++ b/x-pack/plugins/apm/public/selectors/chartSelectors.ts @@ -10,14 +10,14 @@ import { difference, zipObject } from 'lodash'; import mean from 'lodash.mean'; import { rgba } from 'polished'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TimeSeriesAPIResponse } from '../../../../../plugins/apm/server/lib/transactions/charts'; +import { TimeSeriesAPIResponse } from '../../server/lib/transactions/charts'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ApmTimeSeriesResponse } from '../../../../../plugins/apm/server/lib/transactions/charts/get_timeseries_data/transform'; +import { ApmTimeSeriesResponse } from '../../server/lib/transactions/charts/get_timeseries_data/transform'; import { Coordinate, RectCoordinate, TimeSeries -} from '../../../../../plugins/apm/typings/timeseries'; +} from '../../typings/timeseries'; import { asDecimal, tpmUnit, convertTo } from '../utils/formatters'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { getEmptySeries } from '../components/shared/charts/CustomPlot/getEmptySeries'; diff --git a/x-pack/legacy/plugins/apm/public/services/__test__/SessionStorageMock.ts b/x-pack/plugins/apm/public/services/__test__/SessionStorageMock.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/services/__test__/SessionStorageMock.ts rename to x-pack/plugins/apm/public/services/__test__/SessionStorageMock.ts diff --git a/x-pack/legacy/plugins/apm/public/services/__test__/callApi.test.ts b/x-pack/plugins/apm/public/services/__test__/callApi.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/services/__test__/callApi.test.ts rename to x-pack/plugins/apm/public/services/__test__/callApi.test.ts diff --git a/x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts b/x-pack/plugins/apm/public/services/__test__/callApmApi.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/services/__test__/callApmApi.test.ts rename to x-pack/plugins/apm/public/services/__test__/callApmApi.test.ts diff --git a/x-pack/legacy/plugins/apm/public/services/rest/callApi.ts b/x-pack/plugins/apm/public/services/rest/callApi.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/services/rest/callApi.ts rename to x-pack/plugins/apm/public/services/rest/callApi.ts diff --git a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts similarity index 89% rename from x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts rename to x-pack/plugins/apm/public/services/rest/createCallApmApi.ts index 2fffb40d353fc..1027e8b885d71 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/createCallApmApi.ts +++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts @@ -6,9 +6,9 @@ import { HttpSetup } from 'kibana/public'; import { callApi, FetchOptions } from './callApi'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { APMAPI } from '../../../../../../plugins/apm/server/routes/create_apm_api'; +import { APMAPI } from '../../../server/routes/create_apm_api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Client } from '../../../../../../plugins/apm/server/routes/typings'; +import { Client } from '../../../server/routes/typings'; export type APMClient = Client<APMAPI['_S']>; export type APMClientOptions = Omit<FetchOptions, 'query' | 'body'> & { diff --git a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts b/x-pack/plugins/apm/public/services/rest/index_pattern.ts similarity index 76% rename from x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts rename to x-pack/plugins/apm/public/services/rest/index_pattern.ts index 1efcc98bbbd66..ac7a0d3cf734b 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/index_pattern.ts +++ b/x-pack/plugins/apm/public/services/rest/index_pattern.ts @@ -12,3 +12,9 @@ export const createStaticIndexPattern = async () => { pathname: '/api/apm/index_pattern/static' }); }; + +export const getApmIndexPatternTitle = async () => { + return await callApmApi({ + pathname: '/api/apm/index_pattern/title' + }); +}; diff --git a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts b/x-pack/plugins/apm/public/services/rest/ml.ts similarity index 91% rename from x-pack/legacy/plugins/apm/public/services/rest/ml.ts rename to x-pack/plugins/apm/public/services/rest/ml.ts index 0cd1bdf907531..b333a08d2eb05 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts +++ b/x-pack/plugins/apm/public/services/rest/ml.ts @@ -9,14 +9,14 @@ import { PROCESSOR_EVENT, SERVICE_NAME, TRANSACTION_TYPE -} from '../../../../../../plugins/apm/common/elasticsearch_fieldnames'; +} from '../../../common/elasticsearch_fieldnames'; import { getMlJobId, getMlPrefix, encodeForMlApi -} from '../../../../../../plugins/apm/common/ml_job_constants'; +} from '../../../common/ml_job_constants'; import { callApi } from './callApi'; -import { ESFilter } from '../../../../../../plugins/apm/typings/elasticsearch'; +import { ESFilter } from '../../../typings/elasticsearch'; import { callApmApi } from './createCallApmApi'; interface MlResponseItem { diff --git a/x-pack/legacy/plugins/apm/public/services/rest/watcher.ts b/x-pack/plugins/apm/public/services/rest/watcher.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/services/rest/watcher.ts rename to x-pack/plugins/apm/public/services/rest/watcher.ts diff --git a/x-pack/legacy/plugins/apm/public/new-platform/setHelpExtension.ts b/x-pack/plugins/apm/public/setHelpExtension.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/new-platform/setHelpExtension.ts rename to x-pack/plugins/apm/public/setHelpExtension.ts diff --git a/x-pack/legacy/plugins/apm/public/style/variables.ts b/x-pack/plugins/apm/public/style/variables.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/style/variables.ts rename to x-pack/plugins/apm/public/style/variables.ts diff --git a/x-pack/legacy/plugins/apm/public/new-platform/toggleAppLinkInNav.ts b/x-pack/plugins/apm/public/toggleAppLinkInNav.ts similarity index 91% rename from x-pack/legacy/plugins/apm/public/new-platform/toggleAppLinkInNav.ts rename to x-pack/plugins/apm/public/toggleAppLinkInNav.ts index c807cebf97525..8204e1a022d7e 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/toggleAppLinkInNav.ts +++ b/x-pack/plugins/apm/public/toggleAppLinkInNav.ts @@ -5,7 +5,7 @@ */ import { CoreStart } from 'kibana/public'; -import { ConfigSchema } from './plugin'; +import { ConfigSchema } from '.'; export function toggleAppLinkInNav(core: CoreStart, { ui }: ConfigSchema) { if (ui.enabled === false) { diff --git a/x-pack/legacy/plugins/apm/public/new-platform/updateBadge.ts b/x-pack/plugins/apm/public/updateBadge.ts similarity index 99% rename from x-pack/legacy/plugins/apm/public/new-platform/updateBadge.ts rename to x-pack/plugins/apm/public/updateBadge.ts index b3e29bb891c23..10849754313c4 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/updateBadge.ts +++ b/x-pack/plugins/apm/public/updateBadge.ts @@ -10,7 +10,6 @@ import { CoreStart } from 'kibana/public'; export function setReadonlyBadge({ application, chrome }: CoreStart) { const canSave = application.capabilities.apm.save; const { setBadge } = chrome; - setBadge( !canSave ? { diff --git a/x-pack/legacy/plugins/apm/public/utils/__test__/flattenObject.test.ts b/x-pack/plugins/apm/public/utils/__test__/flattenObject.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/__test__/flattenObject.test.ts rename to x-pack/plugins/apm/public/utils/__test__/flattenObject.test.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/flattenObject.ts b/x-pack/plugins/apm/public/utils/flattenObject.ts similarity index 94% rename from x-pack/legacy/plugins/apm/public/utils/flattenObject.ts rename to x-pack/plugins/apm/public/utils/flattenObject.ts index 020bfec2cbd6a..295ea1f9f900f 100644 --- a/x-pack/legacy/plugins/apm/public/utils/flattenObject.ts +++ b/x-pack/plugins/apm/public/utils/flattenObject.ts @@ -5,7 +5,7 @@ */ import { compact, isObject } from 'lodash'; -import { Maybe } from '../../../../../plugins/apm/typings/common'; +import { Maybe } from '../../typings/common'; export interface KeyValuePair { key: string; diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters/__test__/datetime.test.ts b/x-pack/plugins/apm/public/utils/formatters/__test__/datetime.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/formatters/__test__/datetime.test.ts rename to x-pack/plugins/apm/public/utils/formatters/__test__/datetime.test.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters/__test__/duration.test.ts b/x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/formatters/__test__/duration.test.ts rename to x-pack/plugins/apm/public/utils/formatters/__test__/duration.test.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters/__test__/formatters.test.ts b/x-pack/plugins/apm/public/utils/formatters/__test__/formatters.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/formatters/__test__/formatters.test.ts rename to x-pack/plugins/apm/public/utils/formatters/__test__/formatters.test.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters/__test__/size.test.ts b/x-pack/plugins/apm/public/utils/formatters/__test__/size.test.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/formatters/__test__/size.test.ts rename to x-pack/plugins/apm/public/utils/formatters/__test__/size.test.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters/datetime.ts b/x-pack/plugins/apm/public/utils/formatters/datetime.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/formatters/datetime.ts rename to x-pack/plugins/apm/public/utils/formatters/datetime.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters/duration.ts b/x-pack/plugins/apm/public/utils/formatters/duration.ts similarity index 96% rename from x-pack/legacy/plugins/apm/public/utils/formatters/duration.ts rename to x-pack/plugins/apm/public/utils/formatters/duration.ts index 681d876ca3beb..39341e1ff4443 100644 --- a/x-pack/legacy/plugins/apm/public/utils/formatters/duration.ts +++ b/x-pack/plugins/apm/public/utils/formatters/duration.ts @@ -7,10 +7,10 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { memoize } from 'lodash'; -import { NOT_AVAILABLE_LABEL } from '../../../../../../plugins/apm/common/i18n'; +import { NOT_AVAILABLE_LABEL } from '../../../common/i18n'; import { asDecimal, asInteger } from './formatters'; import { TimeUnit } from './datetime'; -import { Maybe } from '../../../../../../plugins/apm/typings/common'; +import { Maybe } from '../../../typings/common'; interface FormatterOptions { defaultValue?: string; diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters/formatters.ts b/x-pack/plugins/apm/public/utils/formatters/formatters.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/formatters/formatters.ts rename to x-pack/plugins/apm/public/utils/formatters/formatters.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters/index.ts b/x-pack/plugins/apm/public/utils/formatters/index.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/formatters/index.ts rename to x-pack/plugins/apm/public/utils/formatters/index.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters/size.ts b/x-pack/plugins/apm/public/utils/formatters/size.ts similarity index 95% rename from x-pack/legacy/plugins/apm/public/utils/formatters/size.ts rename to x-pack/plugins/apm/public/utils/formatters/size.ts index 8fe6ebf3e573d..2cdf8af1d46de 100644 --- a/x-pack/legacy/plugins/apm/public/utils/formatters/size.ts +++ b/x-pack/plugins/apm/public/utils/formatters/size.ts @@ -5,7 +5,7 @@ */ import { memoize } from 'lodash'; import { asDecimal } from './formatters'; -import { Maybe } from '../../../../../../plugins/apm/typings/common'; +import { Maybe } from '../../../typings/common'; function asKilobytes(value: number) { return `${asDecimal(value / 1000)} KB`; diff --git a/x-pack/legacy/plugins/apm/public/utils/getRangeFromTimeSeries.ts b/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts similarity index 88% rename from x-pack/legacy/plugins/apm/public/utils/getRangeFromTimeSeries.ts rename to x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts index 4301ead2fc79f..1865d5ae574a7 100644 --- a/x-pack/legacy/plugins/apm/public/utils/getRangeFromTimeSeries.ts +++ b/x-pack/plugins/apm/public/utils/getRangeFromTimeSeries.ts @@ -5,7 +5,7 @@ */ import { flatten } from 'lodash'; -import { TimeSeries } from '../../../../../plugins/apm/typings/timeseries'; +import { TimeSeries } from '../../typings/timeseries'; export const getRangeFromTimeSeries = (timeseries: TimeSeries[]) => { const dataPoints = flatten(timeseries.map(series => series.data)); diff --git a/x-pack/legacy/plugins/apm/public/utils/history.ts b/x-pack/plugins/apm/public/utils/history.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/history.ts rename to x-pack/plugins/apm/public/utils/history.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/httpStatusCodeToColor.ts b/x-pack/plugins/apm/public/utils/httpStatusCodeToColor.ts similarity index 100% rename from x-pack/legacy/plugins/apm/public/utils/httpStatusCodeToColor.ts rename to x-pack/plugins/apm/public/utils/httpStatusCodeToColor.ts diff --git a/x-pack/legacy/plugins/apm/public/utils/isValidCoordinateValue.ts b/x-pack/plugins/apm/public/utils/isValidCoordinateValue.ts similarity index 84% rename from x-pack/legacy/plugins/apm/public/utils/isValidCoordinateValue.ts rename to x-pack/plugins/apm/public/utils/isValidCoordinateValue.ts index f7c13603c3535..c36efc232b782 100644 --- a/x-pack/legacy/plugins/apm/public/utils/isValidCoordinateValue.ts +++ b/x-pack/plugins/apm/public/utils/isValidCoordinateValue.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Maybe } from '../../../../../plugins/apm/typings/common'; +import { Maybe } from '../../typings/common'; export const isValidCoordinateValue = (value: Maybe<number>): value is number => value !== null && value !== undefined; diff --git a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx similarity index 96% rename from x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx rename to x-pack/plugins/apm/public/utils/testHelpers.tsx index 36c0e18777bfd..def41a1cabd61 100644 --- a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -16,14 +16,14 @@ import { render, waitForElement } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { MemoryRouter } from 'react-router-dom'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { APMConfig } from '../../../../../plugins/apm/server'; +import { APMConfig } from '../../server'; import { LocationProvider } from '../context/LocationContext'; -import { PromiseReturnType } from '../../../../../plugins/apm/typings/common'; +import { PromiseReturnType } from '../../typings/common'; import { ESFilter, ESSearchResponse, ESSearchRequest -} from '../../../../../plugins/apm/typings/elasticsearch'; +} from '../../typings/elasticsearch'; import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; export function toJson(wrapper: ReactWrapper) { diff --git a/x-pack/legacy/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md similarity index 92% rename from x-pack/legacy/plugins/apm/readme.md rename to x-pack/plugins/apm/readme.md index e8e2514c83fcb..62465e920d793 100644 --- a/x-pack/legacy/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -32,7 +32,7 @@ _Docker Compose is required_ ### E2E (Cypress) tests ```sh -x-pack/legacy/plugins/apm/e2e/run-e2e.sh +x-pack/plugins/apm/e2e/run-e2e.sh ``` _Starts Kibana (:5701), APM Server (:8201) and Elasticsearch (:9201). Ingests sample data into Elasticsearch via APM Server and runs the Cypress tests_ @@ -94,13 +94,13 @@ _Note: Run the following commands from `kibana/`._ #### Prettier ``` -yarn prettier "./x-pack/legacy/plugins/apm/**/*.{tsx,ts,js}" --write +yarn prettier "./x-pack/plugins/apm/**/*.{tsx,ts,js}" --write ``` #### ESLint ``` -yarn eslint ./x-pack/legacy/plugins/apm --fix +yarn eslint ./x-pack/plugins/apm --fix ``` ### Setup default APM users @@ -117,7 +117,7 @@ For testing purposes APM uses 3 custom users: To create the users with the correct roles run the following script: ```sh -node x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js --role-suffix <github-username-or-something-unique> +node x-pack/plugins/apm/scripts/setup-kibana-security.js --role-suffix <github-username-or-something-unique> ``` The users will be created with the password specified in kibana.dev.yml for `elasticsearch.password` diff --git a/x-pack/legacy/plugins/apm/scripts/.gitignore b/x-pack/plugins/apm/scripts/.gitignore similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/.gitignore rename to x-pack/plugins/apm/scripts/.gitignore diff --git a/x-pack/legacy/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts b/x-pack/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts rename to x-pack/plugins/apm/scripts/kibana-security/setup-custom-kibana-user-role.ts diff --git a/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig.js b/x-pack/plugins/apm/scripts/optimize-tsconfig.js similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/optimize-tsconfig.js rename to x-pack/plugins/apm/scripts/optimize-tsconfig.js diff --git a/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/optimize.js b/x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/optimize.js rename to x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js diff --git a/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/paths.js b/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js similarity index 90% rename from x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/paths.js rename to x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js index cab55a2526202..aeccd403c5ce6 100644 --- a/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/paths.js +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js @@ -5,7 +5,7 @@ */ const path = require('path'); -const xpackRoot = path.resolve(__dirname, '../../../../..'); +const xpackRoot = path.resolve(__dirname, '../../../..'); const kibanaRoot = path.resolve(xpackRoot, '..'); const tsconfigTpl = path.resolve(__dirname, './tsconfig.json'); diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json new file mode 100644 index 0000000000000..5e05d3962eccb --- /dev/null +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json @@ -0,0 +1,10 @@ +{ + "include": [ + "./plugins/apm/**/*", + "./typings/**/*" + ], + "exclude": [ + "**/__fixtures__/**/*", + "./plugins/apm/e2e/cypress/**/*" + ] +} diff --git a/x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/unoptimize.js b/x-pack/plugins/apm/scripts/optimize-tsconfig/unoptimize.js similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/optimize-tsconfig/unoptimize.js rename to x-pack/plugins/apm/scripts/optimize-tsconfig/unoptimize.js diff --git a/x-pack/legacy/plugins/apm/scripts/package.json b/x-pack/plugins/apm/scripts/package.json similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/package.json rename to x-pack/plugins/apm/scripts/package.json diff --git a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js b/x-pack/plugins/apm/scripts/setup-kibana-security.js similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js rename to x-pack/plugins/apm/scripts/setup-kibana-security.js diff --git a/x-pack/legacy/plugins/apm/scripts/storybook.js b/x-pack/plugins/apm/scripts/storybook.js similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/storybook.js rename to x-pack/plugins/apm/scripts/storybook.js diff --git a/x-pack/legacy/plugins/apm/scripts/unoptimize-tsconfig.js b/x-pack/plugins/apm/scripts/unoptimize-tsconfig.js similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/unoptimize-tsconfig.js rename to x-pack/plugins/apm/scripts/unoptimize-tsconfig.js diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js b/x-pack/plugins/apm/scripts/upload-telemetry-data.js similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js rename to x-pack/plugins/apm/scripts/upload-telemetry-data.js diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts similarity index 100% rename from x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts rename to x-pack/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts similarity index 97% rename from x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts rename to x-pack/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts index 8d76063a7fdf6..390609996874b 100644 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts @@ -18,7 +18,7 @@ import { CollectTelemetryParams, collectDataTelemetry // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; +} from '../../server/lib/apm_telemetry/collect_data_telemetry'; interface GenerateOptions { days: number; diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts new file mode 100644 index 0000000000000..4f69a3a3bd213 --- /dev/null +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -0,0 +1,208 @@ +/* + * 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. + */ + +// This script downloads the telemetry mapping, runs the APM telemetry tasks, +// generates a bunch of randomized data based on the downloaded sample, +// and uploads it to a cluster of your choosing in the same format as it is +// stored in the telemetry cluster. Its purpose is twofold: +// - Easier testing of the telemetry tasks +// - Validate whether we can run the queries we want to on the telemetry data + +import fs from 'fs'; +import path from 'path'; +// @ts-ignore +import { Octokit } from '@octokit/rest'; +import { merge, chunk, flatten, pick, identity } from 'lodash'; +import axios from 'axios'; +import yaml from 'js-yaml'; +import { Client } from 'elasticsearch'; +import { argv } from 'yargs'; +import { promisify } from 'util'; +import { Logger } from 'kibana/server'; +// @ts-ignore +import consoleStamp from 'console-stamp'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { CollectTelemetryParams } from '../../server/lib/apm_telemetry/collect_data_telemetry'; +import { downloadTelemetryTemplate } from './download-telemetry-template'; +import mapping from '../../mappings.json'; +import { generateSampleDocuments } from './generate-sample-documents'; + +consoleStamp(console, '[HH:MM:ss.l]'); + +const githubToken = process.env.GITHUB_TOKEN; + +if (!githubToken) { + throw new Error('GITHUB_TOKEN was not provided.'); +} + +const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); +const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); +const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); + +const xpackTelemetryIndexName = 'xpack-phone-home'; + +const loadedKibanaConfig = (yaml.safeLoad( + fs.readFileSync( + fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, + 'utf8' + ) +) || {}) as {}; + +const cliEsCredentials = pick( + { + 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, + 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, + 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST + }, + identity +) as { + 'elasticsearch.username': string; + 'elasticsearch.password': string; + 'elasticsearch.hosts': string; +}; + +const config = { + 'apm_oss.transactionIndices': 'apm-*', + 'apm_oss.metricsIndices': 'apm-*', + 'apm_oss.errorIndices': 'apm-*', + 'apm_oss.spanIndices': 'apm-*', + 'apm_oss.onboardingIndices': 'apm-*', + 'apm_oss.sourcemapIndices': 'apm-*', + 'elasticsearch.hosts': 'http://localhost:9200', + ...loadedKibanaConfig, + ...cliEsCredentials +}; + +async function uploadData() { + const octokit = new Octokit({ + auth: githubToken + }); + + const telemetryTemplate = await downloadTelemetryTemplate(octokit); + + const kibanaMapping = mapping['apm-telemetry']; + + const httpAuth = + config['elasticsearch.username'] && config['elasticsearch.password'] + ? { + username: config['elasticsearch.username'], + password: config['elasticsearch.password'] + } + : null; + + const client = new Client({ + host: config['elasticsearch.hosts'], + ...(httpAuth + ? { + httpAuth: `${httpAuth.username}:${httpAuth.password}` + } + : {}) + }); + + if (argv.clear) { + try { + await promisify(client.indices.delete.bind(client))({ + index: xpackTelemetryIndexName + }); + } catch (err) { + // 404 = index not found, totally okay + if (err.status !== 404) { + throw err; + } + } + } + + const axiosInstance = axios.create({ + baseURL: config['elasticsearch.hosts'], + ...(httpAuth ? { auth: httpAuth } : {}) + }); + + const newTemplate = merge(telemetryTemplate, { + settings: { + index: { mapping: { total_fields: { limit: 10000 } } } + } + }); + + // override apm mapping instead of merging + newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; + + await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); + + const sampleDocuments = await generateSampleDocuments({ + collectTelemetryParams: { + logger: (console as unknown) as Logger, + indices: { + ...config, + apmCustomLinkIndex: '.apm-custom-links', + apmAgentConfigurationIndex: '.apm-agent-configuration' + }, + search: body => { + return promisify(client.search.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + indicesStats: body => { + return promisify(client.indices.stats.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + transportRequest: (params => { + return axiosInstance[params.method](params.path); + }) as CollectTelemetryParams['transportRequest'] + } + }); + + const chunks = chunk(sampleDocuments, 250); + + await chunks.reduce<Promise<any>>((prev, documents) => { + return prev.then(async () => { + const body = flatten( + documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) + ); + + return promisify(client.bulk.bind(client))({ + body, + refresh: true + }).then((response: any) => { + if (response.errors) { + const firstError = response.items.filter( + (item: any) => item.index.status >= 400 + )[0].index.error; + throw new Error(`Failed to upload documents: ${firstError.reason} `); + } + }); + }); + }, Promise.resolve()); +} + +uploadData() + .catch(e => { + if ('response' in e) { + if (typeof e.response === 'string') { + // eslint-disable-next-line no-console + console.log(e.response); + } else { + // eslint-disable-next-line no-console + console.log( + JSON.stringify( + e.response, + ['status', 'statusText', 'headers', 'data'], + 2 + ) + ); + } + } else { + // eslint-disable-next-line no-console + console.log(e); + } + process.exit(1); + }) + .then(() => { + // eslint-disable-next-line no-console + console.log('Finished uploading generated telemetry data'); + }); diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts new file mode 100644 index 0000000000000..ae0f5510cd80e --- /dev/null +++ b/x-pack/plugins/apm/server/feature.ts @@ -0,0 +1,72 @@ +/* + * 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 APM_FEATURE = { + id: 'apm', + name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', { + defaultMessage: 'APM' + }), + order: 900, + icon: 'apmApp', + navLinkId: 'apm', + app: ['apm', 'kibana'], + catalogue: ['apm'], + // see x-pack/plugins/features/common/feature_kibana_privileges.ts + privileges: { + all: { + app: ['apm', 'kibana'], + api: [ + 'apm', + 'apm_write', + 'actions-read', + 'actions-all', + 'alerting-read', + 'alerting-all' + ], + catalogue: ['apm'], + savedObject: { + all: ['alert', 'action', 'action_task_params'], + read: [] + }, + ui: [ + 'show', + 'save', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete' + ] + }, + read: { + app: ['apm', 'kibana'], + api: [ + 'apm', + 'actions-read', + 'actions-all', + 'alerting-read', + 'alerting-all' + ], + catalogue: ['apm'], + savedObject: { + all: ['alert', 'action', 'action_task_params'], + read: [] + }, + ui: [ + 'show', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete' + ] + } + } +}; diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 77655568a7e9c..9009008790631 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -73,4 +73,4 @@ export type APMConfig = ReturnType<typeof mergeConfigs>; export const plugin = (initContext: PluginInitializerContext) => new APMPlugin(initContext); -export { APMPlugin, APMPluginContract } from './plugin'; +export { APMPlugin, APMPluginSetup } from './plugin'; diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/queries.test.ts b/x-pack/plugins/apm/server/lib/errors/distribution/queries.test.ts index 0d539a09f091b..fcc456c653303 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/queries.test.ts @@ -8,7 +8,7 @@ import { getErrorDistribution } from './get_distribution'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../../public/utils/testHelpers'; describe('error distribution queries', () => { let mock: SearchParamsMock; diff --git a/x-pack/plugins/apm/server/lib/errors/queries.test.ts b/x-pack/plugins/apm/server/lib/errors/queries.test.ts index 5b063c2fb2b61..f1e5d31efd4bd 100644 --- a/x-pack/plugins/apm/server/lib/errors/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/errors/queries.test.ts @@ -9,7 +9,7 @@ import { getErrorGroups } from './get_error_groups'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../public/utils/testHelpers'; describe('error queries', () => { let mock: SearchParamsMock; diff --git a/x-pack/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/plugins/apm/server/lib/helpers/es_client.ts index c22084dbb7168..45a7eca46caba 100644 --- a/x-pack/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/es_client.ts @@ -20,16 +20,15 @@ import { ESSearchResponse } from '../../../typings/elasticsearch'; import { OBSERVER_VERSION_MAJOR } from '../../../common/elasticsearch_fieldnames'; -import { pickKeys } from '../../../../../legacy/plugins/apm/public/utils/pickKeys'; +import { pickKeys } from '../../../common/utils/pick_keys'; import { APMRequestHandlerContext } from '../../routes/typings'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; // `type` was deprecated in 7.0 export type APMIndexDocumentParams<T> = Omit<IndexDocumentParams<T>, 'type'>; -interface IndexPrivileges { +export interface IndexPrivileges { has_all_requested: boolean; - username: string; index: Record<string, { read: boolean }>; } diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 8e8cf698a84cf..40a2a0e7216a0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -39,19 +39,6 @@ function getMockRequest() { _debug: false } }, - __LEGACY: { - server: { - plugins: { - elasticsearch: { - getCluster: jest.fn().mockReturnValue({ callWithInternalUser: {} }) - } - }, - savedObjects: { - SavedObjectsClient: jest.fn(), - getSavedObjectsRepository: jest.fn() - } - } - }, core: { elasticsearch: { dataClient: { diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts new file mode 100644 index 0000000000000..b3b40bac7bd54 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_apm_index_pattern_title.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { APMRequestHandlerContext } from '../../routes/typings'; + +export function getApmIndexPatternTitle(context: APMRequestHandlerContext) { + return context.config['apm_oss.indexPattern']; +} diff --git a/x-pack/plugins/apm/server/lib/metrics/queries.test.ts b/x-pack/plugins/apm/server/lib/metrics/queries.test.ts index ac4e13ae442cf..f276fa69e20e3 100644 --- a/x-pack/plugins/apm/server/lib/metrics/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/metrics/queries.test.ts @@ -12,7 +12,7 @@ import { getThreadCountChart } from './by_agent/java/thread_count'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../public/utils/testHelpers'; import { SERVICE_NODE_NAME_MISSING } from '../../../common/service_nodes'; describe('metrics queries', () => { diff --git a/x-pack/plugins/apm/server/lib/security/get_indices_privileges.test.ts b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.test.ts new file mode 100644 index 0000000000000..c1bc48f4ed1fd --- /dev/null +++ b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.test.ts @@ -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 { Setup } from '../helpers/setup_request'; +import { getIndicesPrivileges } from './get_indices_privileges'; + +describe('getIndicesPrivileges', () => { + const indices = { + apm_oss: { + errorIndices: 'apm-*', + metricsIndices: 'apm-*', + transactionIndices: 'apm-*', + spanIndices: 'apm-*' + } + }; + it('return that the user has privileges when security plugin is disabled', async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + const error = { + message: + 'no handler found for uri [/_security/user/_has_privileges]', + statusCode: 400 + }; + throw error; + } + } + } as unknown) as Setup; + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: false + }); + expect(privileges).toEqual({ + has_all_requested: true, + index: {} + }); + }); + it('throws when an error happens while fetching indices privileges', async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + throw new Error('unknow error'); + } + } + } as unknown) as Setup; + await expect( + getIndicesPrivileges({ setup, isSecurityPluginEnabled: true }) + ).rejects.toThrowError('unknow error'); + }); + it("has privileges to read from 'apm-*'", async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + return Promise.resolve({ + has_all_requested: true, + index: { 'apm-*': { read: true } } + }); + } + } + } as unknown) as Setup; + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: true + }); + + expect(privileges).toEqual({ + has_all_requested: true, + index: { + 'apm-*': { + read: true + } + } + }); + }); + + it("doesn't have privileges to read from 'apm-*'", async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + return Promise.resolve({ + has_all_requested: false, + index: { 'apm-*': { read: false } } + }); + } + } + } as unknown) as Setup; + + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: true + }); + + expect(privileges).toEqual({ + has_all_requested: false, + index: { + 'apm-*': { + read: false + } + } + }); + }); + it("doesn't have privileges on multiple indices", async () => { + const setup = ({ + indices: { + apm_oss: { + errorIndices: 'apm-error-*', + metricsIndices: 'apm-metrics-*', + transactionIndices: 'apm-trasanction-*', + spanIndices: 'apm-span-*' + } + }, + client: { + hasPrivileges: () => { + return Promise.resolve({ + has_all_requested: false, + index: { + 'apm-error-*': { read: false }, + 'apm-trasanction-*': { read: false }, + 'apm-metrics-*': { read: true }, + 'apm-span-*': { read: true } + } + }); + } + } + } as unknown) as Setup; + + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: true + }); + + expect(privileges).toEqual({ + has_all_requested: false, + index: { + 'apm-error-*': { read: false }, + 'apm-trasanction-*': { read: false }, + 'apm-metrics-*': { read: true }, + 'apm-span-*': { read: true } + } + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts index 1a80a13b2ad19..46ed64f518bb8 100644 --- a/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts +++ b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts @@ -4,8 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ import { Setup } from '../helpers/setup_request'; +import { IndexPrivileges } from '../helpers/es_client'; + +export async function getIndicesPrivileges({ + setup, + isSecurityPluginEnabled +}: { + setup: Setup; + isSecurityPluginEnabled: boolean; +}): Promise<IndexPrivileges> { + // When security plugin is not enabled, returns that the user has all requested privileges. + if (!isSecurityPluginEnabled) { + return { has_all_requested: true, index: {} }; + } -export async function getIndicesPrivileges(setup: Setup) { const { client, indices } = setup; const response = await client.hasPrivileges({ index: [ @@ -20,5 +32,5 @@ export async function getIndicesPrivileges(setup: Setup) { } ] }); - return response.index; + return response; } diff --git a/x-pack/plugins/apm/server/lib/security/get_permissions.ts b/x-pack/plugins/apm/server/lib/security/get_permissions.ts deleted file mode 100644 index ed2a1f64e7f84..0000000000000 --- a/x-pack/plugins/apm/server/lib/security/get_permissions.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 { Setup } from '../helpers/setup_request'; - -export async function getPermissions(setup: Setup) { - const { client, indices } = setup; - - const params = { - index: Object.values(indices), - body: { - size: 0, - query: { - match_all: {} - } - } - }; - - try { - await client.search(params); - return { hasPermission: true }; - } catch (e) { - // If 403, it means the user doesnt have permission. - if (e.status === 403) { - return { hasPermission: false }; - } - // if any other error happens, throw it. - throw e; - } -} diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 17b595385a84e..adb2c9b7cb084 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -17,6 +17,8 @@ import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { dedupeConnections } from './dedupe_connections'; import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; import { getTraceSampleIds } from './get_trace_sample_ids'; +import { addAnomaliesToServicesData } from './ml_helpers'; +import { getMlIndex } from '../../../common/ml_job_constants'; export interface IEnvOptions { setup: Setup & SetupTimeRange; @@ -137,19 +139,58 @@ async function getServicesData(options: IEnvOptions) { ); } +function getAnomaliesData(options: IEnvOptions) { + const { client } = options.setup; + + const params = { + index: getMlIndex('*'), + body: { + size: 0, + query: { + exists: { + field: 'bucket_span' + } + }, + aggs: { + jobs: { + terms: { + field: 'job_id', + size: 10 + }, + aggs: { + max_score: { + max: { + field: 'anomaly_score' + } + } + } + } + } + } + }; + + return client.search(params); +} + +export type AnomaliesResponse = PromiseReturnType<typeof getAnomaliesData>; export type ConnectionsResponse = PromiseReturnType<typeof getConnectionData>; export type ServicesResponse = PromiseReturnType<typeof getServicesData>; - export type ServiceMapAPIResponse = PromiseReturnType<typeof getServiceMap>; export async function getServiceMap(options: IEnvOptions) { - const [connectionData, servicesData] = await Promise.all([ + const [connectionData, servicesData, anomaliesData] = await Promise.all([ getConnectionData(options), - getServicesData(options) + getServicesData(options), + getAnomaliesData(options) ]); + const servicesDataWithAnomalies = addAnomaliesToServicesData( + servicesData, + anomaliesData + ); + return dedupeConnections({ ...connectionData, - services: servicesData + services: servicesDataWithAnomalies }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts new file mode 100644 index 0000000000000..c6680ecd6375b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.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 { AnomaliesResponse } from './get_service_map'; +import { addAnomaliesToServicesData } from './ml_helpers'; + +describe('addAnomaliesToServicesData', () => { + it('adds anomalies to services data', () => { + const servicesData = [ + { + 'service.name': 'opbeans-ruby', + 'agent.name': 'ruby', + 'service.environment': null, + 'service.framework.name': 'Ruby on Rails' + }, + { + 'service.name': 'opbeans-java', + 'agent.name': 'java', + 'service.environment': null, + 'service.framework.name': null + } + ]; + + const anomaliesResponse = { + aggregations: { + jobs: { + buckets: [ + { + key: 'opbeans-ruby-request-high_mean_response_time', + max_score: { value: 50 } + }, + { + key: 'opbeans-java-request-high_mean_response_time', + max_score: { value: 100 } + } + ] + } + } + }; + + const result = [ + { + 'service.name': 'opbeans-ruby', + 'agent.name': 'ruby', + 'service.environment': null, + 'service.framework.name': 'Ruby on Rails', + max_score: 50, + severity: 'major' + }, + { + 'service.name': 'opbeans-java', + 'agent.name': 'java', + 'service.environment': null, + 'service.framework.name': null, + max_score: 100, + severity: 'critical' + } + ]; + + expect( + addAnomaliesToServicesData( + servicesData, + (anomaliesResponse as unknown) as AnomaliesResponse + ) + ).toEqual(result); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts new file mode 100644 index 0000000000000..26a964bfb4dd2 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames'; +import { + getMlJobServiceName, + getSeverity +} from '../../../common/ml_job_constants'; +import { AnomaliesResponse, ServicesResponse } from './get_service_map'; + +export function addAnomaliesToServicesData( + servicesData: ServicesResponse, + anomaliesResponse: AnomaliesResponse +) { + const anomaliesMap = ( + anomaliesResponse.aggregations?.jobs.buckets ?? [] + ).reduce<{ + [key: string]: { max_score?: number }; + }>((previousValue, currentValue) => { + const key = getMlJobServiceName(currentValue.key.toString()); + + return { + ...previousValue, + [key]: { + max_score: Math.max( + previousValue[key]?.max_score ?? 0, + currentValue.max_score.value ?? 0 + ) + } + }; + }, {}); + + const servicesDataWithAnomalies = servicesData.map(service => { + const score = anomaliesMap[service[SERVICE_NAME]]?.max_score; + + return { + ...service, + max_score: score, + severity: getSeverity(score) + }; + }); + + return servicesDataWithAnomalies; +} diff --git a/x-pack/plugins/apm/server/lib/service_nodes/queries.test.ts b/x-pack/plugins/apm/server/lib/service_nodes/queries.test.ts index 672d568b3a5e3..80cd94b1549d7 100644 --- a/x-pack/plugins/apm/server/lib/service_nodes/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/service_nodes/queries.test.ts @@ -13,7 +13,7 @@ import { getServiceNodes } from './'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../public/utils/testHelpers'; import { getServiceNodeMetadata } from '../services/get_service_node_metadata'; import { SERVICE_NODE_NAME_MISSING } from '../../../common/service_nodes'; diff --git a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts index 0e52982c6de28..614014ee37afc 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/index.test.ts @@ -7,7 +7,7 @@ import { getServiceAnnotations } from '.'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../../public/utils/testHelpers'; import noVersions from './__fixtures__/no_versions.json'; import oneVersion from './__fixtures__/one_version.json'; import multipleVersions from './__fixtures__/multiple_versions.json'; diff --git a/x-pack/plugins/apm/server/lib/services/queries.test.ts b/x-pack/plugins/apm/server/lib/services/queries.test.ts index e75d9ad05648c..16490ace77744 100644 --- a/x-pack/plugins/apm/server/lib/services/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/services/queries.test.ts @@ -12,7 +12,7 @@ import { hasHistoricalAgentData } from './get_services/has_historical_agent_data import { SearchParamsMock, inspectSearchParams -} from '../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../public/utils/testHelpers'; describe('services queries', () => { let mock: SearchParamsMock; diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/queries.test.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/queries.test.ts index b951b7f350eed..a1a915e6a84a5 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/queries.test.ts @@ -12,7 +12,7 @@ import { searchConfigurations } from './search_configurations'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../../public/utils/testHelpers'; import { findExactConfiguration } from './find_exact_configuration'; describe('agent configuration queries', () => { diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.test.ts index 9bd6c78797605..5d0bf329368f7 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.test.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.test.ts @@ -5,7 +5,7 @@ */ import { Setup } from '../../helpers/setup_request'; -import { mockNow } from '../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +import { mockNow } from '../../../../public/utils/testHelpers'; import { CustomLink } from '../../../../common/custom_link/custom_link_types'; import { createOrUpdateCustomLink } from './create_or_update_custom_link'; diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.test.ts index 514e473b8e78c..0254660e3523f 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.test.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.test.ts @@ -6,7 +6,7 @@ import { inspectSearchParams, SearchParamsMock -} from '../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../../public/utils/testHelpers'; import { getTransaction } from './get_transaction'; import { Setup } from '../../helpers/setup_request'; import { diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.test.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.test.ts index 45610e36786b7..6a67c30bee197 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.test.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.test.ts @@ -8,7 +8,7 @@ import { listCustomLinks } from './list_custom_links'; import { inspectSearchParams, SearchParamsMock -} from '../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../../public/utils/testHelpers'; import { Setup } from '../../helpers/setup_request'; import { SERVICE_NAME, diff --git a/x-pack/plugins/apm/server/lib/traces/queries.test.ts b/x-pack/plugins/apm/server/lib/traces/queries.test.ts index 0c2ac4d0f9201..871d0fd1c7fb6 100644 --- a/x-pack/plugins/apm/server/lib/traces/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/traces/queries.test.ts @@ -8,7 +8,7 @@ import { getTraceItems } from './get_trace_items'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../public/utils/testHelpers'; describe('trace queries', () => { let mock: SearchParamsMock; diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap index 580cafff95e0c..64f06ad0a81cd 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap @@ -16,6 +16,9 @@ Array [ "p95": Object { "percentiles": Object { "field": "transaction.duration.us", + "hdr": Object { + "number_of_significant_value_digits": 2, + }, "percents": Array [ 95, ], @@ -126,6 +129,9 @@ Array [ "p95": Object { "percentiles": Object { "field": "transaction.duration.us", + "hdr": Object { + "number_of_significant_value_digits": 2, + }, "percents": Array [ 95, ], diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap index 1096c1638f3f2..b93f842b878cb 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap @@ -14,6 +14,9 @@ Object { "p95": Object { "percentiles": Object { "field": "transaction.duration.us", + "hdr": Object { + "number_of_significant_value_digits": 2, + }, "percents": Array [ 95, ], @@ -120,6 +123,9 @@ Object { "p95": Object { "percentiles": Object { "field": "transaction.duration.us", + "hdr": Object { + "number_of_significant_value_digits": 2, + }, "percents": Array [ 95, ], diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index 39f2be551ab6e..fb1aafc2d6c95 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -83,7 +83,11 @@ export function transactionGroupsFetcher( sample: { top_hits: { size: 1, sort } }, avg: { avg: { field: TRANSACTION_DURATION } }, p95: { - percentiles: { field: TRANSACTION_DURATION, percents: [95] } + percentiles: { + field: TRANSACTION_DURATION, + percents: [95], + hdr: { number_of_significant_value_digits: 2 } + } }, sum: { sum: { field: TRANSACTION_DURATION } } } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts b/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts index 8d6495c2e0b5f..73122d8580134 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/queries.test.ts @@ -8,7 +8,7 @@ import { transactionGroupsFetcher } from './fetcher'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../public/utils/testHelpers'; describe('transaction group queries', () => { let mock: SearchParamsMock; diff --git a/x-pack/plugins/apm/server/lib/transactions/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/transactions/__snapshots__/queries.test.ts.snap index 49e0e0669c241..cc5900919f829 100644 --- a/x-pack/plugins/apm/server/lib/transactions/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transactions/__snapshots__/queries.test.ts.snap @@ -333,6 +333,9 @@ Object { "pct": Object { "percentiles": Object { "field": "transaction.duration.us", + "hdr": Object { + "number_of_significant_value_digits": 2, + }, "percents": Array [ 95, 99, @@ -425,6 +428,9 @@ Object { "pct": Object { "percentiles": Object { "field": "transaction.duration.us", + "hdr": Object { + "number_of_significant_value_digits": 2, + }, "percents": Array [ 95, 99, @@ -522,6 +528,9 @@ Object { "pct": Object { "percentiles": Object { "field": "transaction.duration.us", + "hdr": Object { + "number_of_significant_value_digits": 2, + }, "percents": Array [ 95, 99, diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/__snapshots__/fetcher.test.ts.snap b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/__snapshots__/fetcher.test.ts.snap index 6c8430a3e71cf..25ebb15fd73e8 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/__snapshots__/fetcher.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/__snapshots__/fetcher.test.ts.snap @@ -21,6 +21,9 @@ Array [ "pct": Object { "percentiles": Object { "field": "transaction.duration.us", + "hdr": Object { + "number_of_significant_value_digits": 2, + }, "percents": Array [ 95, 99, diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts index 8a2e01c9a7891..e33b98592da2d 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts @@ -69,7 +69,11 @@ export function timeseriesFetcher({ aggs: { avg: { avg: { field: TRANSACTION_DURATION } }, pct: { - percentiles: { field: TRANSACTION_DURATION, percents: [95, 99] } + percentiles: { + field: TRANSACTION_DURATION, + percents: [95, 99], + hdr: { number_of_significant_value_digits: 2 } + } } } }, diff --git a/x-pack/plugins/apm/server/lib/transactions/queries.test.ts b/x-pack/plugins/apm/server/lib/transactions/queries.test.ts index 116738da5ef9b..a9e4204fde1ad 100644 --- a/x-pack/plugins/apm/server/lib/transactions/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/queries.test.ts @@ -11,7 +11,7 @@ import { getTransaction } from './get_transaction'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../public/utils/testHelpers'; describe('transaction queries', () => { let mock: SearchParamsMock; diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/queries.test.ts b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/queries.test.ts index 21cc35da72cb9..b72186f528f28 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/queries.test.ts @@ -8,7 +8,7 @@ import { getLocalUIFilters } from './'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../../public/utils/testHelpers'; import { getServicesProjection } from '../../../../common/projections/services'; describe('local ui filter queries', () => { diff --git a/x-pack/plugins/apm/server/lib/ui_filters/queries.test.ts b/x-pack/plugins/apm/server/lib/ui_filters/queries.test.ts index 63c8c3e494bb0..079ab64f32db3 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/ui_filters/queries.test.ts @@ -8,7 +8,7 @@ import { getEnvironments } from './get_environments'; import { SearchParamsMock, inspectSearchParams -} from '../../../../../legacy/plugins/apm/public/utils/testHelpers'; +} from '../../../public/utils/testHelpers'; describe('ui filter queries', () => { let mock: SearchParamsMock; diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index b434d41982f4c..29ab618cbdd0a 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -10,10 +10,9 @@ import { CoreStart, Logger } from 'src/core/server'; -import { Observable, combineLatest, AsyncSubject } from 'rxjs'; +import { Observable, combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; -import { Server } from 'hapi'; -import { once } from 'lodash'; +import { SecurityPluginSetup } from '../../security/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { TaskManagerSetupContract } from '../../task_manager/server'; import { AlertingPlugin } from '../../alerting/server'; @@ -31,24 +30,20 @@ import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_ import { LicensingPluginSetup } from '../../licensing/public'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../../plugins/features/server'; +import { APM_FEATURE } from './feature'; +import { apmIndices, apmTelemetry } from './saved_objects'; -export interface LegacySetup { - server: Server; -} - -export interface APMPluginContract { +export interface APMPluginSetup { config$: Observable<APMConfig>; - registerLegacyAPI: (__LEGACY: LegacySetup) => void; getApmIndices: () => ReturnType<typeof getApmIndices>; } -export class APMPlugin implements Plugin<APMPluginContract> { +export class APMPlugin implements Plugin<APMPluginSetup> { private currentConfig?: APMConfig; private logger?: Logger; - legacySetup$: AsyncSubject<LegacySetup>; constructor(private readonly initContext: PluginInitializerContext) { this.initContext = initContext; - this.legacySetup$ = new AsyncSubject(); } public async setup( @@ -62,6 +57,8 @@ export class APMPlugin implements Plugin<APMPluginContract> { taskManager?: TaskManagerSetupContract; alerting?: AlertingPlugin['setup']; actions?: ActionsPlugin['setup']; + features: FeaturesPluginSetup; + security?: SecurityPluginSetup; } ) { this.logger = this.initContext.logger.get(); @@ -70,6 +67,9 @@ export class APMPlugin implements Plugin<APMPluginContract> { map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) ); + core.savedObjects.registerType(apmIndices); + core.savedObjects.registerType(apmTelemetry); + if (plugins.actions && plugins.alerting) { registerApmAlerts({ alerting: plugins.alerting, @@ -78,14 +78,6 @@ export class APMPlugin implements Plugin<APMPluginContract> { }); } - this.legacySetup$.subscribe(__LEGACY => { - createApmApi().init(core, { - config$: mergedConfig$, - logger: this.logger!, - __LEGACY - }); - }); - this.currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); if ( @@ -116,13 +108,18 @@ export class APMPlugin implements Plugin<APMPluginContract> { } }) ); + plugins.features.registerFeature(APM_FEATURE); + + createApmApi().init(core, { + config$: mergedConfig$, + logger: this.logger!, + plugins: { + security: plugins.security + } + }); return { config$: mergedConfig$, - registerLegacyAPI: once((__LEGACY: LegacySetup) => { - this.legacySetup$.next(__LEGACY); - this.legacySetup$.complete(); - }), getApmIndices: async () => getApmIndices({ savedObjectsClient: await getInternalSavedObjectsClient(core), diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index 312dae1d1f9d2..6236fcb0a6942 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -9,7 +9,6 @@ import { CoreSetup, Logger } from 'src/core/server'; import { Params } from '../typings'; import { BehaviorSubject } from 'rxjs'; import { APMConfig } from '../..'; -import { LegacySetup } from '../../plugin'; const getCoreMock = () => { const get = jest.fn(); @@ -41,7 +40,7 @@ const getCoreMock = () => { logger: ({ error: jest.fn() } as unknown) as Logger, - __LEGACY: {} as LegacySetup + plugins: {} } }; }; diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index e216574f8a02e..9b611a0bbd6bc 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -30,7 +30,7 @@ export function createApi() { factoryFns.push(fn); return this as any; }, - init(core, { config$, logger, __LEGACY }) { + init(core, { config$, logger, plugins }) { const router = core.http.createRouter(); let config = {} as APMConfig; @@ -136,13 +136,13 @@ export function createApi() { request, context: { ...context, - __LEGACY, // Only return values for parameters that have runtime types, // but always include query as _debug is always set even if // it's not defined in the route. params: pick(parsedParams, ...Object.keys(params), 'query'), config, - logger + logger, + plugins } }); diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 57b3f282852c4..7964d8b0268e8 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -6,7 +6,8 @@ import { staticIndexPatternRoute, - dynamicIndexPatternRoute + dynamicIndexPatternRoute, + apmIndexPatternTitleRoute } from './index_pattern'; import { errorDistributionRoute, @@ -73,6 +74,7 @@ const createApmApi = () => { // index pattern .add(staticIndexPatternRoute) .add(dynamicIndexPatternRoute) + .add(apmIndexPatternTitleRoute) // Errors .add(errorDistributionRoute) diff --git a/x-pack/plugins/apm/server/routes/index_pattern.ts b/x-pack/plugins/apm/server/routes/index_pattern.ts index b7964dc8e91ed..e296057203ff1 100644 --- a/x-pack/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/plugins/apm/server/routes/index_pattern.ts @@ -8,6 +8,7 @@ import { createStaticIndexPattern } from '../lib/index_pattern/create_static_ind import { createRoute } from './create_route'; import { setupRequest } from '../lib/helpers/setup_request'; import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; +import { getApmIndexPatternTitle } from '../lib/index_pattern/get_apm_index_pattern_title'; export const staticIndexPatternRoute = createRoute(core => ({ method: 'POST', @@ -38,3 +39,10 @@ export const dynamicIndexPatternRoute = createRoute(() => ({ return { dynamicIndexPattern }; } })); + +export const apmIndexPatternTitleRoute = createRoute(() => ({ + path: '/api/apm/index_pattern/title', + handler: async ({ context }) => { + return getApmIndexPatternTitle(context); + } +})); diff --git a/x-pack/plugins/apm/server/routes/security.ts b/x-pack/plugins/apm/server/routes/security.ts index 0a8222b665d83..1e2a302ab9a4a 100644 --- a/x-pack/plugins/apm/server/routes/security.ts +++ b/x-pack/plugins/apm/server/routes/security.ts @@ -12,6 +12,10 @@ export const indicesPrivilegesRoute = createRoute(() => ({ path: '/api/apm/security/indices_privileges', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return getIndicesPrivileges(setup); + return getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: + context.plugins.security?.license.isEnabled() ?? false + }); } })); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 3dc485630c180..e049255eb8ec8 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -14,7 +14,9 @@ import { import { PickByValue, Optional } from 'utility-types'; import { Observable } from 'rxjs'; import { Server } from 'hapi'; -import { FetchOptions } from '../../../../legacy/plugins/apm/public/services/rest/callApi'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FetchOptions } from '../../public/services/rest/callApi'; +import { SecurityPluginSetup } from '../../../security/public'; import { APMConfig } from '..'; export interface Params { @@ -61,8 +63,8 @@ export type APMRequestHandlerContext< params: { query: { _debug: boolean } } & TDecodedParams; config: APMConfig; logger: Logger; - __LEGACY: { - server: APMLegacyServer; + plugins: { + security?: SecurityPluginSetup; }; }; @@ -107,7 +109,9 @@ export interface ServerAPI<TRouteState extends RouteState> { context: { config$: Observable<APMConfig>; logger: Logger; - __LEGACY: { server: Server }; + plugins: { + security?: SecurityPluginSetup; + }; } ) => void; } diff --git a/x-pack/plugins/apm/server/saved_objects/apm_indices.ts b/x-pack/plugins/apm/server/saved_objects/apm_indices.ts new file mode 100644 index 0000000000000..c641f4546aae7 --- /dev/null +++ b/x-pack/plugins/apm/server/saved_objects/apm_indices.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 { SavedObjectsType } from 'src/core/server'; + +export const apmIndices: SavedObjectsType = { + name: 'apm-indices', + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { + 'apm_oss.sourcemapIndices': { + type: 'keyword' + }, + 'apm_oss.errorIndices': { + type: 'keyword' + }, + 'apm_oss.onboardingIndices': { + type: 'keyword' + }, + 'apm_oss.spanIndices': { + type: 'keyword' + }, + 'apm_oss.transactionIndices': { + type: 'keyword' + }, + 'apm_oss.metricsIndices': { + type: 'keyword' + } + } + } +}; diff --git a/x-pack/plugins/apm/server/saved_objects/apm_telemetry.ts b/x-pack/plugins/apm/server/saved_objects/apm_telemetry.ts new file mode 100644 index 0000000000000..f711e85076e14 --- /dev/null +++ b/x-pack/plugins/apm/server/saved_objects/apm_telemetry.ts @@ -0,0 +1,921 @@ +/* + * 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 { SavedObjectsType } from 'src/core/server'; + +export const apmTelemetry: SavedObjectsType = { + name: 'apm-telemetry', + hidden: false, + namespaceType: 'agnostic', + mappings: { + properties: { + agents: { + properties: { + dotnet: { + properties: { + agent: { + properties: { + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + service: { + properties: { + framework: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + language: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + runtime: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + } + } + } + } + }, + go: { + properties: { + agent: { + properties: { + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + service: { + properties: { + framework: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + language: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + runtime: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + } + } + } + } + }, + java: { + properties: { + agent: { + properties: { + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + service: { + properties: { + framework: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + language: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + runtime: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + } + } + } + } + }, + 'js-base': { + properties: { + agent: { + properties: { + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + service: { + properties: { + framework: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + language: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + runtime: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + } + } + } + } + }, + nodejs: { + properties: { + agent: { + properties: { + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + service: { + properties: { + framework: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + language: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + runtime: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + } + } + } + } + }, + python: { + properties: { + agent: { + properties: { + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + service: { + properties: { + framework: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + language: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + runtime: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + } + } + } + } + }, + ruby: { + properties: { + agent: { + properties: { + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + service: { + properties: { + framework: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + language: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + runtime: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + } + } + } + } + }, + 'rum-js': { + properties: { + agent: { + properties: { + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + service: { + properties: { + framework: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + language: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + }, + runtime: { + properties: { + composite: { + type: 'keyword', + ignore_above: 1024 + }, + name: { + type: 'keyword', + ignore_above: 1024 + }, + version: { + type: 'keyword', + ignore_above: 1024 + } + } + } + } + } + } + } + } + }, + counts: { + properties: { + agent_configuration: { + properties: { + all: { + type: 'long' + } + } + }, + error: { + properties: { + '1d': { + type: 'long' + }, + all: { + type: 'long' + } + } + }, + max_error_groups_per_service: { + properties: { + '1d': { + type: 'long' + } + } + }, + max_transaction_groups_per_service: { + properties: { + '1d': { + type: 'long' + } + } + }, + metric: { + properties: { + '1d': { + type: 'long' + }, + all: { + type: 'long' + } + } + }, + onboarding: { + properties: { + '1d': { + type: 'long' + }, + all: { + type: 'long' + } + } + }, + services: { + properties: { + '1d': { + type: 'long' + } + } + }, + sourcemap: { + properties: { + '1d': { + type: 'long' + }, + all: { + type: 'long' + } + } + }, + span: { + properties: { + '1d': { + type: 'long' + }, + all: { + type: 'long' + } + } + }, + traces: { + properties: { + '1d': { + type: 'long' + } + } + }, + transaction: { + properties: { + '1d': { + type: 'long' + }, + all: { + type: 'long' + } + } + } + } + }, + cardinality: { + properties: { + user_agent: { + properties: { + original: { + properties: { + all_agents: { + properties: { + '1d': { + type: 'long' + } + } + }, + rum: { + properties: { + '1d': { + type: 'long' + } + } + } + } + } + } + }, + transaction: { + properties: { + name: { + properties: { + all_agents: { + properties: { + '1d': { + type: 'long' + } + } + }, + rum: { + properties: { + '1d': { + type: 'long' + } + } + } + } + } + } + } + } + }, + has_any_services: { + type: 'boolean' + }, + indices: { + properties: { + all: { + properties: { + total: { + properties: { + docs: { + properties: { + count: { + type: 'long' + } + } + }, + store: { + properties: { + size_in_bytes: { + type: 'long' + } + } + } + } + } + } + }, + shards: { + properties: { + total: { + type: 'long' + } + } + } + } + }, + integrations: { + properties: { + ml: { + properties: { + all_jobs_count: { + type: 'long' + } + } + } + } + }, + retainment: { + properties: { + error: { + properties: { + ms: { + type: 'long' + } + } + }, + metric: { + properties: { + ms: { + type: 'long' + } + } + }, + onboarding: { + properties: { + ms: { + type: 'long' + } + } + }, + span: { + properties: { + ms: { + type: 'long' + } + } + }, + transaction: { + properties: { + ms: { + type: 'long' + } + } + } + } + }, + services_per_agent: { + properties: { + dotnet: { + type: 'long', + null_value: 0 + }, + go: { + type: 'long', + null_value: 0 + }, + java: { + type: 'long', + null_value: 0 + }, + 'js-base': { + type: 'long', + null_value: 0 + }, + nodejs: { + type: 'long', + null_value: 0 + }, + python: { + type: 'long', + null_value: 0 + }, + ruby: { + type: 'long', + null_value: 0 + }, + 'rum-js': { + type: 'long', + null_value: 0 + } + } + }, + tasks: { + properties: { + agent_configuration: { + properties: { + took: { + properties: { + ms: { + type: 'long' + } + } + } + } + }, + agents: { + properties: { + took: { + properties: { + ms: { + type: 'long' + } + } + } + } + }, + cardinality: { + properties: { + took: { + properties: { + ms: { + type: 'long' + } + } + } + } + }, + groupings: { + properties: { + took: { + properties: { + ms: { + type: 'long' + } + } + } + } + }, + indices_stats: { + properties: { + took: { + properties: { + ms: { + type: 'long' + } + } + } + } + }, + integrations: { + properties: { + took: { + properties: { + ms: { + type: 'long' + } + } + } + } + }, + processor_events: { + properties: { + took: { + properties: { + ms: { + type: 'long' + } + } + } + } + }, + services: { + properties: { + took: { + properties: { + ms: { + type: 'long' + } + } + } + } + }, + versions: { + properties: { + took: { + properties: { + ms: { + type: 'long' + } + } + } + } + } + } + }, + version: { + properties: { + apm_server: { + properties: { + major: { + type: 'long' + }, + minor: { + type: 'long' + }, + patch: { + type: 'long' + } + } + } + } + } + } as SavedObjectsType['mappings']['properties'] + } +}; diff --git a/x-pack/plugins/apm/server/saved_objects/index.ts b/x-pack/plugins/apm/server/saved_objects/index.ts new file mode 100644 index 0000000000000..30c557c526356 --- /dev/null +++ b/x-pack/plugins/apm/server/saved_objects/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 { apmIndices } from './apm_indices'; +export { apmTelemetry } from './apm_telemetry'; diff --git a/x-pack/plugins/apm/server/tutorial/index.ts b/x-pack/plugins/apm/server/tutorial/index.ts index 1fbac0b6495df..76e2456afa5df 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 6f500caabd824..1c3e41ec12780 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 bdd2c75f161e9..754529a198552 100644 --- a/x-pack/plugins/apm/typings/common.d.ts +++ b/x-pack/plugins/apm/typings/common.d.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import '../../../legacy/plugins/infra/types/rison_node'; -import '../../../legacy/plugins/infra/types/eui'; +import '../../../typings/rison_node'; +import '../../infra/types/eui'; // EUIBasicTable -import '../../../legacy/plugins/reporting/public/components/report_listing'; -// .svg -import '../../../legacy/plugins/canvas/types/webpack'; +import '../../reporting/public/components/report_listing'; +import './apm_rum_react'; // Allow unknown properties in an object export type AllowUnknownProperties<T> = T extends Array<infer X> diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 8a8d256cf4273..0739e8e6120bf 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -86,6 +86,7 @@ export interface AggregationOptionsByType { percentiles: { field: string; percents?: number[]; + hdr?: { number_of_significant_value_digits: number }; }; extended_stats: { field: string; diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 3cc442d591f3f..2d6ab43228aa1 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "canvas"], "server": true, - "ui": false, - "requiredPlugins": ["expressions", "features", "home"], + "ui": true, + "requiredPlugins": ["data", "embeddable", "expressions", "features", "home", "inspector", "uiActions"], "optionalPlugins": ["usageCollection"] } diff --git a/x-pack/plugins/canvas/public/index.ts b/x-pack/plugins/canvas/public/index.ts new file mode 100644 index 0000000000000..736d5ccc0f8e5 --- /dev/null +++ b/x-pack/plugins/canvas/public/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'kibana/public'; +import { CanvasPlugin } from '../../../legacy/plugins/canvas/public/plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => new CanvasPlugin(); diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_bool_array.js b/x-pack/plugins/canvas/server/lib/build_bool_array.js similarity index 100% rename from x-pack/legacy/plugins/canvas/server/lib/build_bool_array.js rename to x-pack/plugins/canvas/server/lib/build_bool_array.js diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_es_request.js b/x-pack/plugins/canvas/server/lib/build_es_request.js similarity index 100% rename from x-pack/legacy/plugins/canvas/server/lib/build_es_request.js rename to x-pack/plugins/canvas/server/lib/build_es_request.js diff --git a/x-pack/legacy/plugins/canvas/server/lib/filters.js b/x-pack/plugins/canvas/server/lib/filters.js similarity index 100% rename from x-pack/legacy/plugins/canvas/server/lib/filters.js rename to x-pack/plugins/canvas/server/lib/filters.js diff --git a/x-pack/legacy/plugins/canvas/server/lib/format_response.js b/x-pack/plugins/canvas/server/lib/format_response.js similarity index 100% rename from x-pack/legacy/plugins/canvas/server/lib/format_response.js rename to x-pack/plugins/canvas/server/lib/format_response.js diff --git a/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js b/x-pack/plugins/canvas/server/lib/get_es_filter.js similarity index 79% rename from x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js rename to x-pack/plugins/canvas/server/lib/get_es_filter.js index e8a4d704118e8..7c025ed8dee9b 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js +++ b/x-pack/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/server/lib/normalize_type.js b/x-pack/plugins/canvas/server/lib/normalize_type.js similarity index 100% rename from x-pack/legacy/plugins/canvas/server/lib/normalize_type.js rename to x-pack/plugins/canvas/server/lib/normalize_type.js diff --git a/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js b/x-pack/plugins/canvas/server/lib/query_es_sql.js similarity index 100% rename from x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js rename to x-pack/plugins/canvas/server/lib/query_es_sql.js diff --git a/x-pack/legacy/plugins/canvas/server/lib/sanitize_name.js b/x-pack/plugins/canvas/server/lib/sanitize_name.js similarity index 100% rename from x-pack/legacy/plugins/canvas/server/lib/sanitize_name.js rename to x-pack/plugins/canvas/server/lib/sanitize_name.js diff --git a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts index b82f84b931d73..5282a246de6b6 100644 --- a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts +++ b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { API_ROUTE } from '../../../../../legacy/plugins/canvas/common/lib'; import { catchErrorHandler } from '../catch_error_handler'; // @ts-ignore unconverted lib -import { normalizeType } from '../../../../../legacy/plugins/canvas/server/lib/normalize_type'; +import { normalizeType } from '../../lib/normalize_type'; import { RouteInitializerDeps } from '..'; const ESFieldsRequestSchema = schema.object({ diff --git a/x-pack/plugins/canvas/server/saved_objects/custom_element.ts b/x-pack/plugins/canvas/server/saved_objects/custom_element.ts index 14223455cbc21..d26f6dd8fb16e 100644 --- a/x-pack/plugins/canvas/server/saved_objects/custom_element.ts +++ b/x-pack/plugins/canvas/server/saved_objects/custom_element.ts @@ -30,4 +30,12 @@ export const customElementType: SavedObjectsType = { }, }, migrations: {}, + management: { + icon: 'canvasApp', + defaultSearchField: 'name', + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.displayName; + }, + }, }; diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad.ts b/x-pack/plugins/canvas/server/saved_objects/workpad.ts index 918f4bf991076..2e9570b1b83be 100644 --- a/x-pack/plugins/canvas/server/saved_objects/workpad.ts +++ b/x-pack/plugins/canvas/server/saved_objects/workpad.ts @@ -30,4 +30,18 @@ export const workpadType: SavedObjectsType = { migrations: { '7.0.0': removeAttributesId, }, + management: { + importableAndExportable: true, + icon: 'canvasApp', + defaultSearchField: 'name', + getTitle(obj) { + return obj.attributes.name; + }, + getInAppUrl(obj) { + return { + path: `/app/canvas#/workpad/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'canvas.show', + }; + }, + }, }; diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 1f08a41024905..586c2b0c2a259 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -16,6 +16,7 @@ export { ActionTypeExecutorResult } from '../../../../actions/server/types'; const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); const CaseBasicRt = rt.type({ + connector_id: rt.string, description: rt.string, status: StatusRt, tags: rt.array(rt.string), @@ -127,35 +128,34 @@ export const ServiceConnectorCommentParamsRt = rt.type({ updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), }); -export const ServiceConnectorCaseParamsRt = rt.intersection([ - rt.type({ - caseId: rt.string, - createdAt: rt.string, - createdBy: ServiceConnectorUserParams, - incidentId: rt.union([rt.string, rt.null]), - title: rt.string, - updatedAt: rt.union([rt.string, rt.null]), - updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), - }), - rt.partial({ - description: rt.string, - comments: rt.array(ServiceConnectorCommentParamsRt), - }), -]); +export const ServiceConnectorCaseParamsRt = rt.type({ + caseId: rt.string, + createdAt: rt.string, + createdBy: ServiceConnectorUserParams, + externalId: rt.union([rt.string, rt.null]), + title: rt.string, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), + description: rt.union([rt.string, rt.null]), + comments: rt.union([rt.array(ServiceConnectorCommentParamsRt), rt.null]), +}); export const ServiceConnectorCaseResponseRt = rt.intersection([ rt.type({ - number: rt.string, - incidentId: rt.string, + title: rt.string, + id: rt.string, pushedDate: rt.string, url: rt.string, }), rt.partial({ comments: rt.array( - rt.type({ - commentId: rt.string, - pushedDate: rt.string, - }) + rt.intersection([ + rt.type({ + commentId: rt.string, + pushedDate: rt.string, + }), + rt.partial({ externalCommentId: rt.string }), + ]) ), }), ]); diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index d92af587d0e92..7d20011a428cf 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -8,10 +8,12 @@ import * as rt from 'io-ts'; import { ActionResult } from '../../../../actions/common'; import { UserRT } from '../user'; +import { JiraFieldsRT } from '../connectors/jira'; +import { ServiceNowFieldsRT } from '../connectors/servicenow'; /* * This types below are related to the service now configuration - * mapping between our case and service-now + * mapping between our case and [service-now, jira] * */ @@ -27,12 +29,7 @@ const CaseFieldRT = rt.union([ rt.literal('comments'), ]); -const ThirdPartyFieldRT = rt.union([ - rt.literal('comments'), - rt.literal('description'), - rt.literal('not_mapped'), - rt.literal('short_description'), -]); +const ThirdPartyFieldRT = rt.union([JiraFieldsRT, ServiceNowFieldsRT, rt.literal('not_mapped')]); export const CasesConfigurationMapsRT = rt.type({ source: CaseFieldRT, diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 2b70a698a5152..0bed0fd8fc57d 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -14,6 +14,7 @@ import { UserRT } from '../user'; const UserActionFieldRt = rt.array( rt.union([ rt.literal('comment'), + rt.literal('connector_id'), rt.literal('description'), rt.literal('pushed'), rt.literal('tags'), diff --git a/x-pack/plugins/case/common/api/connectors/index.ts b/x-pack/plugins/case/common/api/connectors/index.ts new file mode 100644 index 0000000000000..c1fc284c938b7 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/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 './jira'; +export * from './servicenow'; diff --git a/x-pack/plugins/case/common/api/connectors/jira.ts b/x-pack/plugins/case/common/api/connectors/jira.ts new file mode 100644 index 0000000000000..4e4674318ddd8 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/jira.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. + */ + +import * as rt from 'io-ts'; + +export const JiraFieldsRT = rt.union([ + rt.literal('summary'), + rt.literal('description'), + rt.literal('comments'), +]); + +export type JiraFieldsType = rt.TypeOf<typeof JiraFieldsRT>; diff --git a/x-pack/plugins/case/common/api/connectors/servicenow.ts b/x-pack/plugins/case/common/api/connectors/servicenow.ts new file mode 100644 index 0000000000000..fc124bfd46094 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/servicenow.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. + */ + +import * as rt from 'io-ts'; + +export const ServiceNowFieldsRT = rt.union([ + rt.literal('short_description'), + rt.literal('description'), + rt.literal('comments'), +]); + +export type ServiceNowFieldsType = rt.TypeOf<typeof ServiceNowFieldsRT>; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index dcfa46bfa6019..855a5c3d63507 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -27,3 +27,5 @@ export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`; export const ACTION_URL = '/api/action'; export const ACTION_TYPES_URL = '/api/action/types'; + +export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira']; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 75e793a80272f..135eeecdd491a 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -18,6 +18,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [ attributes: { closed_at: null, closed_by: null, + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -46,6 +47,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [ attributes: { closed_at: null, closed_by: null, + connector_id: 'none', created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -74,6 +76,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [ attributes: { closed_at: null, closed_by: null, + connector_id: '123', created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -106,6 +109,7 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [ email: 'testemail@elastic.co', username: 'elastic', }, + connector_id: '123', created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -130,6 +134,35 @@ export const mockCases: Array<SavedObject<CaseAttributes>> = [ }, ]; +export const mockCaseNoConnectorId: SavedObject<Partial<CaseAttributes>> = { + type: 'cases', + id: 'mock-no-connector_id', + attributes: { + closed_at: null, + closed_by: null, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + references: [], + updated_at: '2019-11-25T21:54:48.952Z', + version: 'WzAsMV0=', +}; + export const mockCasesErrorTriggerData = [ { id: 'valid-id', diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index dd9b124ff1b79..90661a7d3897d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -16,8 +16,14 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, flattenCaseSavedObject } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { getConnectorId } from '../helpers'; -export function initPatchCommentApi({ caseService, router, userActionService }: RouteDeps) { +export function initPatchCommentApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.patch( { path: CASE_COMMENTS_URL, @@ -64,7 +70,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDate = new Date().toISOString(); - const [updatedComment, updatedCase] = await Promise.all([ + const [updatedComment, updatedCase, myCaseConfigure] = await Promise.all([ caseService.patchComment({ client, commentId: query.id, @@ -84,6 +90,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: }, version: myCase.version, }), + caseConfigureService.find({ client }), ]); const totalCommentsFindByCases = await caseService.getAllCaseComments({ @@ -95,7 +102,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: perPage: 1, }, }); - + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); const [comments] = await Promise.all([ caseService.getAllCaseComments({ client, @@ -125,16 +132,17 @@ export function initPatchCommentApi({ caseService, router, userActionService }: return response.ok({ body: CaseResponseRt.encode( - flattenCaseSavedObject( - { + flattenCaseSavedObject({ + savedObject: { ...myCase, ...updatedCase, attributes: { ...myCase.attributes, ...updatedCase.attributes }, version: updatedCase.version ?? myCase.version, references: myCase.references, }, - comments.saved_objects - ) + comments: comments.saved_objects, + caseConfigureConnectorId, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index a296d9815f251..486f709b1e7ed 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -16,8 +16,14 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { escapeHatch, transformNewComment, wrapError, flattenCaseSavedObject } from '../../utils'; import { RouteDeps } from '../../types'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { getConnectorId } from '../helpers'; -export function initPostCommentApi({ caseService, router, userActionService }: RouteDeps) { +export function initPostCommentApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.post( { path: CASE_COMMENTS_URL, @@ -45,7 +51,7 @@ export function initPostCommentApi({ caseService, router, userActionService }: R const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); - const [newComment, updatedCase] = await Promise.all([ + const [newComment, updatedCase, myCaseConfigure] = await Promise.all([ caseService.postNewComment({ client, attributes: transformNewComment({ @@ -72,8 +78,10 @@ export function initPostCommentApi({ caseService, router, userActionService }: R }, version: myCase.version, }), + caseConfigureService.find({ client }), ]); + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); const totalCommentsFindByCases = await caseService.getAllCaseComments({ client, caseId, @@ -112,16 +120,17 @@ export function initPostCommentApi({ caseService, router, userActionService }: R return response.ok({ body: CaseResponseRt.encode( - flattenCaseSavedObject( - { + flattenCaseSavedObject({ + savedObject: { ...myCase, ...updatedCase, attributes: { ...myCase.attributes, ...updatedCase.attributes }, version: updatedCase.version ?? myCase.version, references: myCase.references, }, - comments.saved_objects - ) + comments: comments.saved_objects, + caseConfigureConnectorId, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 03bec1fe72d39..5f83e8d6f94f5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -9,7 +9,7 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -export function initGetCaseConfigure({ caseConfigureService, caseService, router }: RouteDeps) { +export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps) { router.get( { path: CASE_CONFIGURE_URL, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index 00575655d4c42..43167d56de015 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -8,14 +8,15 @@ import Boom from 'boom'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common/constants'; +import { + CASE_CONFIGURE_CONNECTORS_URL, + SUPPORTED_CONNECTORS, +} from '../../../../../common/constants'; /* * Be aware that this api will only return 20 connectors */ -const CASE_SERVICE_NOW_ACTION = '.servicenow'; - export function initCaseConfigureGetActionConnector({ caseService, router }: RouteDeps) { router.get( { @@ -30,8 +31,8 @@ export function initCaseConfigureGetActionConnector({ caseService, router }: Rou throw Boom.notFound('Action client have not been found'); } - const results = (await actionsClient.getAll()).filter( - action => action.actionTypeId === CASE_SERVICE_NOW_ACTION + const results = (await actionsClient.getAll()).filter(action => + SUPPORTED_CONNECTORS.includes(action.actionTypeId) ); return response.ok({ body: results }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index 7af1cee494457..9adb1eeb1bca0 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -15,8 +15,9 @@ import { } from '../__fixtures__'; import { initFindCasesApi } from './find_cases'; import { CASES_URL } from '../../../../common/constants'; +import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -describe('GET all cases', () => { +describe('FIND all cases', () => { let routeHandler: RequestHandler<any, any, any>; beforeAll(async () => { routeHandler = await createRoute(initFindCasesApi, 'get'); @@ -37,4 +38,53 @@ describe('GET all cases', () => { expect(response.status).toEqual(200); expect(response.payload.cases).toHaveLength(4); }); + it(`has proper connector id on cases with configured id`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: `${CASES_URL}/_find`, + method: 'get', + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.cases[2].connector_id).toEqual('123'); + }); + it(`adds 'none' connector id to cases without when 3rd party unconfigured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: `${CASES_URL}/_find`, + method: 'get', + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.cases[0].connector_id).toEqual('none'); + }); + it(`adds default connector id to cases without when 3rd party configured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: `${CASES_URL}/_find`, + method: 'get', + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.cases[0].connector_id).toEqual('123'); + }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 40fc0301b058a..cbe26ebe2f642 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -16,6 +16,7 @@ import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; import { RouteDeps, TotalCommentByCase } from '../types'; import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; import { CASES_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => filters?.filter(i => i !== '').join(` ${operator} `); @@ -39,7 +40,7 @@ const buildFilter = ( : `${CASE_SAVED_OBJECT}.attributes.${field}: ${filters}` : ''; -export function initFindCasesApi({ caseService, router }: RouteDeps) { +export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( { path: `${CASES_URL}/_find`, @@ -94,12 +95,12 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { filter: getStatusFilter('closed', myFilters), }, }; - const [cases, openCases, closesCases] = await Promise.all([ + const [cases, openCases, closesCases, myCaseConfigure] = await Promise.all([ caseService.findCases(args), caseService.findCases(argsOpenCases), caseService.findCases(argsClosedCases), + caseConfigureService.find({ client }), ]); - const totalCommentsFindByCases = await Promise.all( cases.saved_objects.map(c => caseService.getAllCaseComments({ @@ -135,7 +136,8 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { cases, openCases.total ?? 0, closesCases.total ?? 0, - totalCommentsByCases + totalCommentsByCases, + getConnectorId(myCaseConfigure) ) ), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts index a8c12d4734b53..6c0b5bdff418d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts @@ -19,6 +19,7 @@ import { import { flattenCaseSavedObject } from '../utils'; import { initGetCaseApi } from './get_case'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; describe('GET case', () => { let routeHandler: RequestHandler<any, any, any>; @@ -44,14 +45,11 @@ describe('GET case', () => { ); const response = await routeHandler(theContext, request, kibanaResponseFactory); - + const savedObject = (mockCases.find(s => s.id === 'mock-id-1') as unknown) as SavedObject< + CaseAttributes + >; expect(response.status).toEqual(200); - expect(response.payload).toEqual( - flattenCaseSavedObject( - (mockCases.find(s => s.id === 'mock-id-1') as unknown) as SavedObject<CaseAttributes>, - [] - ) - ); + expect(response.payload).toEqual(flattenCaseSavedObject({ savedObject })); expect(response.payload.comments).toEqual([]); }); it(`returns an error when thrown from getCase`, async () => { @@ -123,4 +121,75 @@ describe('GET case', () => { expect(response.status).toEqual(400); }); + it(`case w/o connector_id - returns the case with connector id when 3rd party unconfigured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_DETAILS_URL, + method: 'get', + params: { + case_id: 'mock-no-connector_id', + }, + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('none'); + }); + it(`case w/o connector_id - returns the case with connector id when 3rd party configured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_DETAILS_URL, + method: 'get', + params: { + case_id: 'mock-no-connector_id', + }, + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('123'); + }); + it(`case w/ connector_id - returns the case with connector id when case already has connectorId`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASE_DETAILS_URL, + method: 'get', + params: { + case_id: 'mock-id-3', + }, + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('123'); + }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 1e836d38c285c..57b472d3889cc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -10,8 +10,9 @@ import { CaseResponseRt } from '../../../../common/api'; import { RouteDeps } from '../types'; import { flattenCaseSavedObject, wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; -export function initGetCaseApi({ caseService, router }: RouteDeps) { +export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { router.get( { path: CASE_DETAILS_URL, @@ -29,13 +30,25 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { const client = context.core.savedObjects.client; const includeComments = JSON.parse(request.query.includeComments); - const theCase = await caseService.getCase({ - client, - caseId: request.params.case_id, - }); + const [theCase, myCaseConfigure] = await Promise.all([ + caseService.getCase({ + client, + caseId: request.params.case_id, + }), + caseConfigureService.find({ client }), + ]); + + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); if (!includeComments) { - return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, [])) }); + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + caseConfigureConnectorId, + }) + ), + }); } const theComments = await caseService.getAllCaseComments({ @@ -48,7 +61,14 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { }); return response.ok({ - body: CaseResponseRt.encode(flattenCaseSavedObject(theCase, theComments.saved_objects)), + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + caseConfigureConnectorId, + }) + ), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 46c2209d79f7d..b02bc0b4e10a2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -6,7 +6,8 @@ import { get } from 'lodash'; -import { CaseAttributes, CasePatchRequest } from '../../../../common/api'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { CaseAttributes, CasePatchRequest, CasesConfigureAttributes } from '../../../../common/api'; interface CompareArrays { addedItems: string[]; @@ -75,8 +76,20 @@ export const getCaseToUpdate = ( ...acc, [key]: value, }; + } else if (currentValue == null && key === 'connector_id' && value !== currentValue) { + return { + ...acc, + [key]: value, + }; } return acc; }, { id: queryCase.id, version: queryCase.version } ); + +export const getConnectorId = ( + caseConfigure: SavedObjectsFindResponse<CasesConfigureAttributes> +): string => + caseConfigure.saved_objects.length > 0 + ? caseConfigure.saved_objects[0].attributes.connector_id + : 'none'; diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index ac1e67cec52bd..b5100907f246a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -15,6 +15,7 @@ import { mockCaseComments, } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; +import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; describe('PATCH cases', () => { let routeHandler: RequestHandler<any, any, any>; @@ -53,6 +54,7 @@ describe('PATCH cases', () => { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, comments: [], + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', @@ -86,6 +88,7 @@ describe('PATCH cases', () => { const theContext = createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, }) ); @@ -96,6 +99,7 @@ describe('PATCH cases', () => { closed_at: null, closed_by: null, comments: [], + connector_id: '123', created_at: '2019-11-25T22:32:17.947Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'Oh no, a bad meanie going LOLBins all over the place!', @@ -111,6 +115,56 @@ describe('PATCH cases', () => { }, ]); }); + it(`Patches a case without a connector_id`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-no-connector_id', + status: 'closed', + version: 'WzAsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: [mockCaseNoConnectorId], + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload[0].connector_id).toEqual('none'); + }); + it(`Patches a case with a connector_id`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'patch', + body: { + cases: [ + { + id: 'mock-id-3', + status: 'closed', + version: 'WzUsMV0=', + }, + ], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload[0].connector_id).toEqual('123'); + }); it(`Fails with 409 if version does not match`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases', diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 57f9fc20dbf34..6d2a5f943cea9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -18,11 +18,16 @@ import { } from '../../../../common/api'; import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; -import { getCaseToUpdate } from './helpers'; +import { getCaseToUpdate, getConnectorId } from './helpers'; import { buildCaseUserActions } from '../../../services/user_actions/helpers'; import { CASES_URL } from '../../../../common/constants'; -export function initPatchCasesApi({ caseService, router, userActionService }: RouteDeps) { +export function initPatchCasesApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.patch( { path: CASES_URL, @@ -37,10 +42,16 @@ export function initPatchCasesApi({ caseService, router, userActionService }: Ro excess(CasesPatchRequestRt).decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCases = await caseService.getCases({ - client, - caseIds: query.cases.map(q => q.id), - }); + + const [myCases, myCaseConfigure] = await Promise.all([ + caseService.getCases({ + client, + caseIds: query.cases.map(q => q.id), + }), + caseConfigureService.find({ client }), + ]); + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + let nonExistingCases: CasePatchRequest[] = []; const conflictedCases = query.cases.filter(q => { const myCase = myCases.saved_objects.find(c => c.id === q.id); @@ -114,11 +125,14 @@ export function initPatchCasesApi({ caseService, router, userActionService }: Ro .map(myCase => { const updatedCase = updatedCases.saved_objects.find(c => c.id === myCase.id); return flattenCaseSavedObject({ - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - version: updatedCase?.version ?? myCase.version, + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + version: updatedCase?.version ?? myCase.version, + }, + caseConfigureConnectorId, }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 0bbceb5214046..b545eb8b7fb08 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -15,6 +15,7 @@ import { } from '../__fixtures__'; import { initPostCaseApi } from './post_case'; import { CASES_URL } from '../../../../common/constants'; +import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; describe('POST cases', () => { let routeHandler: RequestHandler<any, any, any>; @@ -25,7 +26,7 @@ describe('POST cases', () => { toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), })); }); - it(`Posts a new case`, async () => { + it(`Posts a new case, no connector configured`, async () => { const request = httpServerMock.createKibanaRequest({ path: CASES_URL, method: 'post', @@ -46,6 +47,29 @@ describe('POST cases', () => { expect(response.status).toEqual(200); expect(response.payload.id).toEqual('mock-it'); expect(response.payload.created_by.username).toEqual('awesome'); + expect(response.payload.connector_id).toEqual('none'); + }); + it(`Posts a new case, connector configured`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: CASES_URL, + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + tags: ['defacement'], + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, + }) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.connector_id).toEqual('123'); }); it(`Error if you passing status for a new case`, async () => { @@ -106,6 +130,7 @@ describe('POST cases', () => { const theContext = createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseConfigureSavedObject: mockCaseConfigure, }) ); @@ -115,6 +140,7 @@ describe('POST cases', () => { closed_at: null, closed_by: null, comments: [], + connector_id: '123', created_at: '2019-11-25T21:54:48.952Z', created_by: { email: null, diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 059a8b1affd54..05574698edd44 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -15,8 +15,14 @@ import { CasePostRequestRt, throwErrors, excess, CaseResponseRt } from '../../.. import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; -export function initPostCaseApi({ caseService, router, userActionService }: RouteDeps) { +export function initPostCaseApi({ + caseService, + caseConfigureService, + router, + userActionService, +}: RouteDeps) { router.post( { path: CASES_URL, @@ -34,6 +40,8 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); + const myCaseConfigure = await caseConfigureService.find({ client }); + const connectorId = getConnectorId(myCaseConfigure); const newCase = await caseService.postNewCase({ client, attributes: transformNewCase({ @@ -42,6 +50,7 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout username, full_name, email, + connectorId, }), }); @@ -59,7 +68,13 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout ], }); - return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(newCase, [])) }); + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: newCase, + }) + ), + }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 94ebe24c3d2ae..c6638d292a197 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -16,6 +16,7 @@ import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../.. import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { getConnectorId } from './helpers'; export function initPushCaseUserActionApi({ caseConfigureService, @@ -83,6 +84,11 @@ export function initPushCaseUserActionApi({ ...query, }; + const caseConfigureConnectorId = getConnectorId(myCaseConfigure); + // old case may not have new attribute connector_id, so we default to the configured system + const updateConnectorId = + myCase.attributes.connector_id == null ? { connector_id: caseConfigureConnectorId } : {}; + const [updatedCase, updatedComments] = await Promise.all([ caseService.patchCase({ client, @@ -98,6 +104,7 @@ export function initPushCaseUserActionApi({ external_service: externalService, updated_at: pushedDate, updated_by: { username, full_name, email }, + ...updateConnectorId, }, version: myCase.version, }), @@ -143,14 +150,14 @@ export function initPushCaseUserActionApi({ ]); return response.ok({ body: CaseResponseRt.encode( - flattenCaseSavedObject( - { + flattenCaseSavedObject({ + savedObject: { ...myCase, ...updatedCase, attributes: { ...myCase.attributes, ...updatedCase?.attributes }, references: myCase.references, }, - comments.saved_objects.map(origComment => { + comments: comments.saved_objects.map(origComment => { const updatedComment = updatedComments.saved_objects.find( c => c.id === origComment.id ); @@ -164,8 +171,8 @@ export function initPushCaseUserActionApi({ version: updatedComment?.version ?? origComment.version, references: origComment?.references ?? [], }; - }) - ) + }), + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index a22f4db30bf8d..81156b98bab83 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -18,13 +18,18 @@ import { } from './utils'; import { newCase } from './__mocks__/request_responses'; import { isBoom, boomify } from 'boom'; -import { mockCases, mockCaseComments } from './__fixtures__/mock_saved_objects'; +import { + mockCases, + mockCaseComments, + mockCaseNoConnectorId, +} from './__fixtures__/mock_saved_objects'; describe('Utils', () => { describe('transformNewCase', () => { it('transform correctly', () => { const myCase = { newCase, + connectorId: '123', createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', full_name: 'Elastic', @@ -37,6 +42,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, + connector_id: '123', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, external_service: null, @@ -49,6 +55,7 @@ describe('Utils', () => { it('transform correctly without optional fields', () => { const myCase = { newCase, + connectorId: '123', createdDate: '2020-04-09T09:43:51.778Z', }; @@ -58,6 +65,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, + connector_id: '123', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: undefined, full_name: undefined, username: undefined }, external_service: null, @@ -70,6 +78,7 @@ describe('Utils', () => { it('transform correctly with optional fields as null', () => { const myCase = { newCase, + connectorId: '123', createdDate: '2020-04-09T09:43:51.778Z', email: null, full_name: null, @@ -82,6 +91,7 @@ describe('Utils', () => { ...myCase.newCase, closed_at: null, closed_by: null, + connector_id: '123', created_at: '2020-04-09T09:43:51.778Z', created_by: { email: null, full_name: null, username: null }, external_service: null, @@ -204,7 +214,7 @@ describe('Utils', () => { describe('transformCases', () => { it('transforms correctly', () => { - const totalCommentsByCase = [ + const extraCaseData = [ { caseId: mockCases[0].id, totalComments: 2 }, { caseId: mockCases[1].id, totalComments: 2 }, { caseId: mockCases[2].id, totalComments: 2 }, @@ -215,13 +225,14 @@ describe('Utils', () => { { saved_objects: mockCases, total: mockCases.length, per_page: 10, page: 1 }, 2, 2, - totalCommentsByCase + extraCaseData, + '123' ); expect(res).toEqual({ page: 1, per_page: 10, total: mockCases.length, - cases: flattenCaseSavedObjects(mockCases, totalCommentsByCase), + cases: flattenCaseSavedObjects(mockCases, extraCaseData, '123'), count_open_cases: 2, count_closed_cases: 2, }); @@ -230,14 +241,15 @@ describe('Utils', () => { describe('flattenCaseSavedObjects', () => { it('flattens correctly', () => { - const totalCommentsByCase = [{ caseId: mockCases[0].id, totalComments: 2 }]; + const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }]; - const res = flattenCaseSavedObjects([mockCases[0]], totalCommentsByCase); + const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123'); expect(res).toEqual([ { id: 'mock-id-1', closed_at: null, closed_by: null, + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -262,16 +274,94 @@ describe('Utils', () => { ]); }); - it('it handles total comments correctly', () => { - const totalCommentsByCase = [{ caseId: 'not-exist', totalComments: 2 }]; - - const res = flattenCaseSavedObjects([mockCases[0]], totalCommentsByCase); + it('it handles total comments correctly when caseId is not in extraCaseData', () => { + const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; + const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData, '123'); expect(res).toEqual([ { id: 'mock-id-1', closed_at: null, closed_by: null, + connector_id: 'none', + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 0, + version: 'WzAsMV0=', + }, + ]); + }); + it('inserts missing connectorId', () => { + const extraCaseData = [ + { + caseId: mockCaseNoConnectorId.id, + totalComment: 0, + }, + ]; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData, '123'); + expect(res).toEqual([ + { + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: '123', + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 0, + version: 'WzAsMV0=', + }, + ]); + }); + it('inserts missing connectorId (none)', () => { + const extraCaseData = [ + { + caseId: mockCaseNoConnectorId.id, + totalComment: 0, + }, + ]; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); + expect(res).toEqual([ + { + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: 'none', created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -300,7 +390,7 @@ describe('Utils', () => { describe('flattenCaseSavedObject', () => { it('flattens correctly', () => { const myCase = { ...mockCases[0] }; - const res = flattenCaseSavedObject(myCase, [], 2); + const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 }); expect(res).toEqual({ id: myCase.id, version: myCase.version, @@ -313,7 +403,7 @@ describe('Utils', () => { it('flattens correctly without version', () => { const myCase = { ...mockCases[0] }; myCase.version = undefined; - const res = flattenCaseSavedObject(myCase, [], 2); + const res = flattenCaseSavedObject({ savedObject: myCase, totalComment: 2 }); expect(res).toEqual({ id: myCase.id, version: '0', @@ -326,7 +416,7 @@ describe('Utils', () => { it('flattens correctly with comments', () => { const myCase = { ...mockCases[0] }; const comments = [{ ...mockCaseComments[0] }]; - const res = flattenCaseSavedObject(myCase, comments, 2); + const res = flattenCaseSavedObject({ savedObject: myCase, comments, totalComment: 2 }); expect(res).toEqual({ id: myCase.id, version: myCase.version, @@ -335,6 +425,76 @@ describe('Utils', () => { ...myCase.attributes, }); }); + it('inserts missing connectorId', () => { + const extraCaseData = { + totalComment: 2, + caseConfigureConnectorId: '123', + }; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData }); + expect(res).toEqual({ + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: '123', + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 2, + version: 'WzAsMV0=', + }); + }); + it('inserts missing connectorId (none)', () => { + const extraCaseData = { + totalComment: 2, + caseConfigureConnectorId: 'none', + }; + + // @ts-ignore this is to update old case saved objects to include connector_id + const res = flattenCaseSavedObject({ savedObject: mockCaseNoConnectorId, ...extraCaseData }); + expect(res).toEqual({ + id: mockCaseNoConnectorId.id, + closed_at: null, + closed_by: null, + connector_id: 'none', + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, + title: 'Super Bad Security Issue', + status: 'open', + tags: ['defacement'], + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + comments: [], + totalComment: 2, + version: 'WzAsMV0=', + }); + }); }); describe('transformComments', () => { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index a3df0fc93d2ac..b65205734d569 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -26,12 +26,14 @@ import { import { SortFieldCase, TotalCommentByCase } from './types'; export const transformNewCase = ({ + connectorId, createdDate, email, full_name, newCase, username, }: { + connectorId: string; createdDate: string; email?: string | null; full_name?: string | null; @@ -41,6 +43,7 @@ export const transformNewCase = ({ ...newCase, closed_at: null, closed_by: null, + connector_id: connectorId, created_at: createdDate, created_by: { email, full_name, username }, external_service: null, @@ -86,40 +89,50 @@ export const transformCases = ( cases: SavedObjectsFindResponse<CaseAttributes>, countOpenCases: number, countClosedCases: number, - totalCommentByCase: TotalCommentByCase[] + totalCommentByCase: TotalCommentByCase[], + caseConfigureConnectorId: string = 'none' ): CasesFindResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), + cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase, caseConfigureConnectorId), count_open_cases: countOpenCases, count_closed_cases: countClosedCases, }); export const flattenCaseSavedObjects = ( savedObjects: SavedObjectsFindResponse<CaseAttributes>['saved_objects'], - totalCommentByCase: TotalCommentByCase[] + totalCommentByCase: TotalCommentByCase[], + caseConfigureConnectorId: string = 'none' ): CaseResponse[] => savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject<CaseAttributes>) => { return [ ...acc, - flattenCaseSavedObject( + flattenCaseSavedObject({ savedObject, - [], - totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0 - ), + totalComment: + totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0, + caseConfigureConnectorId, + }), ]; }, []); -export const flattenCaseSavedObject = ( - savedObject: SavedObject<CaseAttributes>, - comments: Array<SavedObject<CommentAttributes>> = [], - totalComment: number = 0 -): CaseResponse => ({ +export const flattenCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = 0, + caseConfigureConnectorId = 'none', +}: { + savedObject: SavedObject<CaseAttributes>; + comments?: Array<SavedObject<CommentAttributes>>; + totalComment?: number; + caseConfigureConnectorId?: string; +}): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), totalComment, + connector_id: savedObject.attributes.connector_id ?? caseConfigureConnectorId, ...savedObject.attributes, }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index cc2b1e74b38c4..26ed6ab7cc0bc 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -49,6 +49,9 @@ export const caseSavedObjectType: SavedObjectsType = { description: { type: 'text', }, + connector_id: { + type: 'keyword', + }, external_service: { properties: { pushed_at: { diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index e89700419b19d..af50b3b394325 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -119,6 +119,7 @@ export const buildCaseUserActionItem = ({ const userActionFieldsAllowed: UserActionField = [ 'comment', + 'connector_id', 'description', 'tags', 'title', diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js index 67f42fb622bf8..ef4a511f276bd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js @@ -55,11 +55,11 @@ export class FollowerIndicesTable extends PureComponent { if (queryText) { return followerIndices.filter(followerIndex => { - const { name, shards } = followerIndex; + const { name, remoteCluster, leaderIndex } = followerIndex; const inName = name.toLowerCase().includes(queryText); - const inRemoteCluster = shards[0].remoteCluster.toLowerCase().includes(queryText); - const inLeaderIndex = shards[0].leaderIndex.toLowerCase().includes(queryText); + const inRemoteCluster = remoteCluster.toLowerCase().includes(queryText); + const inLeaderIndex = leaderIndex.toLowerCase().includes(queryText); return inName || inRemoteCluster || inLeaderIndex; }); @@ -273,7 +273,7 @@ export class FollowerIndicesTable extends PureComponent { }; const selection = { - onSelectionChange: selectedItems => this.setState({ selectedItems }), + onSelectionChange: newSelectedItems => this.setState({ selectedItems: newSelectedItems }), }; const search = { diff --git a/x-pack/plugins/cross_cluster_replication/public/plugin.ts b/x-pack/plugins/cross_cluster_replication/public/plugin.ts index bdaa04e9d53ee..dfe9e4e657c30 100644 --- a/x-pack/plugins/cross_cluster_replication/public/plugin.ts +++ b/x-pack/plugins/cross_cluster_replication/public/plugin.ts @@ -39,7 +39,7 @@ export class CrossClusterReplicationPlugin implements Plugin { const ccrApp = esSection!.registerApp({ id: MANAGEMENT_ID, title: PLUGIN.TITLE, - order: 4, + order: 6, mount: async ({ element, setBreadcrumbs }) => { const { mountApp } = await import('./app'); diff --git a/x-pack/plugins/dashboard_enhanced/README.md b/x-pack/plugins/dashboard_enhanced/README.md new file mode 100644 index 0000000000000..d9296ae158621 --- /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 0000000000000..f416ca97f7110 --- /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 0000000000000..53540a4a1ad2e --- /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 0000000000000..67dc1fd97d521 --- /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<DashboardEnhancedSetupContract>; +export type Start = jest.Mocked<DashboardEnhancedStartContract>; + +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 0000000000000..772e032289bce --- /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<SetupContract, StartContract, SetupDependencies, StartDependencies> { + public readonly drilldowns = new DashboardDrilldownsService(); + + constructor(protected readonly context: PluginInitializerContext) {} + + public setup(core: CoreSetup<StartDependencies>, 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 0000000000000..5ec1b881317d6 --- /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<void> { + 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 0000000000000..81f88e563a258 --- /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<Pick<StartDependencies, 'drilldowns'>>; +} + +export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLYOUT_ADD_DRILLDOWN> { + 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( + <plugins.drilldowns.FlyoutManageDrilldowns + onClose={() => 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 0000000000000..4d2db209fc961 --- /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 0000000000000..555acf1fca5ff --- /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<MockEmbeddable>, + }; + + 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 0000000000000..a4499ba4d757d --- /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<Pick<StartDependencies, 'drilldowns'>>; +} + +export class FlyoutEditDrilldownAction implements ActionByType<typeof OPEN_FLYOUT_EDIT_DRILLDOWN> { + 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( + <plugins.drilldowns.FlyoutManageDrilldowns + onClose={() => handle.close()} + placeContext={context} + viewMode={'manage'} + dynamicActionManager={embeddable.enhancements.dynamicActions} + /> + ), + { + ownFocus: true, + 'data-test-subj': 'editDrilldownFlyout', + } + ); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts new file mode 100644 index 0000000000000..4e2e5eb7092e4 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/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 txtDisplayName = i18n.translate( + 'xpack.dashboard.panel.openFlyoutEditDrilldown.displayName', + { + 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 0000000000000..3e1b37f270708 --- /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 0000000000000..ec3a78e97eae4 --- /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('<MenuItem/>', () => { + const state = createStateContainer<{ events: object[] }>({ events: [] }); + const { getByText, queryByText } = render( + <MenuItem + context={{ + embeddable: ({ + enhancements: { + dynamicActions: ({ state } as unknown) as DynamicActionManager, + }, + } as unknown) as EnhancedEmbeddable, + }} + /> + ); + + 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 0000000000000..5a04e03e03457 --- /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 ( + <EuiFlexGroup alignItems={'center'}> + <EuiFlexItem grow={true}>{txtDisplayName}</EuiFlexItem> + {count > 0 && ( + <EuiFlexItem grow={false}> + <EuiNotificationBadge>{count}</EuiNotificationBadge> + </EuiFlexItem> + )} + </EuiFlexGroup> + ); +}; 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 0000000000000..cccacf701a9ad --- /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<keyof TriggerContextMapping> = []; + constructor( + initialInput: EmbeddableInput, + params: { supportedTriggers?: Array<keyof TriggerContextMapping> } + ) { + super(initialInput, {}, undefined); + this.triggers = params.supportedTriggers ?? []; + } + public render(node: HTMLElement) {} + public reload() {} + public supportedTriggers(): Array<keyof TriggerContextMapping> { + return this.triggers; + } + public getRoot() { + return { + type: this.rootType, + } as Embeddable; + } +} + +export const enhanceEmbeddable = <E extends MockEmbeddable>( + embeddable: E, + uiActions: AdvancedUiActionsStart = uiActionsEnhancedPluginMock.createStartContract() +): EnhancedEmbeddable<E> => { + (embeddable as EnhancedEmbeddable<E>).enhancements = { + dynamicActions: new DynamicActionManager({ + storage: new MemoryActionStorage(), + isCompatible: async () => true, + uiActions, + }), + }; + return embeddable as EnhancedEmbeddable<E>; +}; 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 0000000000000..0161836b2c5b9 --- /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<StartDependencies>, + plugins: SetupDependencies, + { enableDrilldowns }: BootstrapParams + ) { + if (enableDrilldowns) { + this.setupDrilldowns(core, plugins); + } + } + + setupDrilldowns( + core: CoreSetup<StartDependencies>, + { 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 0000000000000..dc19fccf5c92f --- /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<EuiComboBoxOptionOption<string>>, + selectedDashboard?: EuiComboBoxOptionOption<string> +) => { + // 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<Config> { + params: Params; +} + +interface CollectConfigContainerState { + dashboards: Array<EuiComboBoxOptionOption<string>>; + searchString?: string; + isLoading: boolean; + selectedDashboard?: EuiComboBoxOptionOption<string>; + 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 ( + <DashboardDrilldownConfig + activeDashboardId={config.dashboardId} + dashboards={mergeDashboards(dashboards, selectedDashboard)} + currentFilters={config.useCurrentFilters} + keepRange={config.useCurrentDateRange} + isLoading={isLoading} + error={error} + onDashboardSelect={dashboardId => { + 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 0000000000000..f3a966a73509c --- /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 ( + <DashboardDrilldownConfig + activeDashboardId={activeDashboardId} + dashboards={dashboards} + currentFilters={currentFilters} + keepRange={keepRange} + onDashboardSelect={id => 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', () => ( + <DashboardDrilldownConfig + activeDashboardId={'dashboard2'} + dashboards={dashboards} + onDashboardSelect={e => console.log('onDashboardSelect', e)} + onSearchChange={() => {}} + isLoading={false} + /> + )) + .add('with switches', () => ( + <DashboardDrilldownConfig + activeDashboardId={'dashboard2'} + dashboards={dashboards} + onDashboardSelect={e => console.log('onDashboardSelect', e)} + onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} + onKeepRangeToggle={() => console.log('onKeepRangeToggle')} + onSearchChange={() => {}} + isLoading={false} + /> + )) + .add('interactive demo', () => <InteractiveDemo />); 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 0000000000000..edeb7de48d9ac --- /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('<DashboardDrilldownConfig/>'); 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 0000000000000..a41a5fb718219 --- /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<EuiComboBoxOptionOption<string>>; + 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<DashboardDrilldownConfigProps> = ({ + activeDashboardId, + dashboards, + currentFilters, + keepRange, + onDashboardSelect, + onCurrentFiltersToggle, + onKeepRangeToggle, + onSearchChange, + isLoading, + error, +}) => { + const selectedTitle = dashboards.find(item => item.value === activeDashboardId)?.label || ''; + + return ( + <> + <EuiFormRow label={txtChooseDestinationDashboard} fullWidth isInvalid={!!error} error={error}> + <EuiComboBox<string> + 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} + /> + </EuiFormRow> + {!!onCurrentFiltersToggle && ( + <EuiFormRow hasChildLabel={false}> + <EuiSwitch + name="useCurrentFilters" + label={txtUseCurrentFilters} + checked={!!currentFilters} + onChange={onCurrentFiltersToggle} + /> + </EuiFormRow> + )} + {!!onKeepRangeToggle && ( + <EuiFormRow hasChildLabel={false}> + <EuiSwitch + name="useCurrentDateRange" + label={txtUseCurrentDateRange} + checked={!!keepRange} + onChange={onKeepRangeToggle} + /> + </EuiFormRow> + )} + </> + ); +}; 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 0000000000000..a37f2bfa01bd4 --- /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 0000000000000..b9a64a3cc17e6 --- /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 0000000000000..6f6f7412f6b53 --- /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 0000000000000..c34290528d914 --- /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 0000000000000..e2a530b156da5 --- /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 0000000000000..18ee95cb57b3b --- /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<Config>, + 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<string>, + }, + }, + }, + self: {}, + })) as unknown) as StartServicesGetter< + Pick<StartDependencies, 'data' | 'advancedUiActions' | 'share'> + >, + }); + 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<VisualizeEmbeddableContract>; + + 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 0000000000000..848e77384f7f0 --- /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<Pick<StartDependencies, 'data' | 'advancedUiActions' | 'share'>>; +} + +export class DashboardToDashboardDrilldown + implements Drilldown<Config, ActionContext<VisualizeEmbeddableContract>> { + 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<CollectConfigContainer['props']> = props => ( + <CollectConfigContainer {...props} params={this.params} /> + ); + + 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<VisualizeEmbeddableContract> + ): Promise<string> => { + 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<VisualizeEmbeddableContract> + ) => { + 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<VisualizeEmbeddableContract> + ): Promise<string> => { + 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 0000000000000..98b746bafd24a --- /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 0000000000000..914f34980a272 --- /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 0000000000000..1fbff0a7269e2 --- /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<T extends IEmbeddable = IEmbeddable> = + | ValueClickTriggerContext<T> + | RangeSelectTriggerContext<T>; + +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 0000000000000..7be8f1c65da12 --- /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/dashboard_enhanced/public/services/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/index.ts new file mode 100644 index 0000000000000..8cc3e12906531 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/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 './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 0000000000000..5d95c56c31e3b --- /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 4dba07b5a7be3..678c054aa322c 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 4834cc8081374..0000000000000 --- 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<CoreStart['overlays']>; -} - -export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLYOUT_ADD_DRILLDOWN> { - 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(<FlyoutCreateDrilldown context={context} onClose={() => 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 f109da94fcaca..0000000000000 --- 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<CoreStart['overlays']>; -} - -const displayName = i18n.translate('xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Manage drilldowns', -}); - -// mocked data -const drilldrownCount = 2; - -export class FlyoutEditDrilldownAction implements ActionByType<typeof OPEN_FLYOUT_EDIT_DRILLDOWN> { - 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}{' '} - <EuiNotificationBadge color="subdued" style={{ float: 'right' }}> - {drilldrownCount} - </EuiNotificationBadge> - </> - ); - }; - - 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(<FormCreateDrilldown />)); - } -} 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 0000000000000..16b4d3a25d9e5 --- /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', () => ( + <EuiFlyout onClose={() => {}}> + <FlyoutManageDrilldowns placeContext={{}} dynamicActionManager={mockDynamicActionManager} /> + </EuiFlyout> +)); 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 0000000000000..6749b41e81fc7 --- /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<NotificationsStart['toasts']>).addSuccess.mockClear(); + (notifications.toasts as jest.Mocked<NotificationsStart['toasts']>).addError.mockClear(); +}); + +test('Allows to manage drilldowns', async () => { + const screen = render( + <FlyoutManageDrilldowns placeContext={{}} dynamicActionManager={mockDynamicActionManager} /> + ); + + // 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( + <FlyoutManageDrilldowns placeContext={{}} dynamicActionManager={mockDynamicActionManager} /> + ); + // 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( + <FlyoutManageDrilldowns + placeContext={{}} + dynamicActionManager={mockDynamicActionManager} + viewMode={'create'} + onClose={onClose} + /> + ); + // 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( + <FlyoutManageDrilldowns placeContext={{}} dynamicActionManager={mockDynamicActionManager} /> + ); + // 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( + <FlyoutManageDrilldowns placeContext={{}} dynamicActionManager={mockDynamicActionManager} /> + ); + + // 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( + <FlyoutManageDrilldowns placeContext={{}} dynamicActionManager={mockDynamicActionManager} /> + ); + // 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 0000000000000..0d4a67e325e4d --- /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<Context extends object = object> { + 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<string, ActionFactory>); + + return (props: ConnectedFlyoutManageDrilldownsProps) => { + const isCreateOnly = props.viewMode === 'create'; + + const selectedTriggers: Array<keyof TriggerContextMapping> = 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<Routes>( + () => (isCreateOnly ? Routes.Create : Routes.Manage) // initial state is different depending on `viewMode` + ); + const [currentEditId, setCurrentEditId] = useState<string | null>(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 ( + <FlyoutDrilldownWizard + showWelcomeMessage={shouldShowWelcomeMessage} + onWelcomeHideClick={onHideWelcomeMessage} + drilldownActionFactories={actionFactories} + onClose={props.onClose} + mode={route === Routes.Create ? 'create' : 'edit'} + onBack={isCreateOnly ? undefined : () => 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 ( + <FlyoutListManageDrilldowns + showWelcomeMessage={shouldShowWelcomeMessage} + onWelcomeHideClick={onHideWelcomeMessage} + drilldowns={drilldowns.map(mapToDrilldownToDrilldownListItem)} + onDelete={ids => { + setCurrentEditId(null); + deleteDrilldown(ids); + }} + onEdit={id => { + setCurrentEditId(id); + setRoute(Routes.Edit); + }} + onCreate={() => { + setCurrentEditId(null); + setRoute(Routes.Create); + }} + onClose={props.onClose} + /> + ); + } + }; +} + +function useCompatibleActionFactoriesForCurrentContext<Context extends object = object>( + actionFactories: Array<ActionFactory<any>>, + context: Context +) { + const [compatibleActionFactories, setCompatibleActionFactories] = useState< + Array<ActionFactory<any>> + >(); + 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<boolean>(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<void>) { + setIsLoading(true); + try { + await op(); + } catch (e) { + notifications.toasts.addError(e, { + title: toastDrilldownsCRUDError, + }); + if (!isMounted) return; + setIsLoading(false); + return; + } + } + + async function createDrilldown( + action: UiActionsEnhancedSerializedAction<any>, + selectedTriggers: Array<keyof TriggerContextMapping> + ) { + 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<any>, + selectedTriggers: Array<keyof TriggerContextMapping> + ) { + 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 0000000000000..31384860786ef --- /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 0000000000000..f084a3e563c23 --- /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 0000000000000..47a04222286cb --- /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<DynamicActionManager> { + public readonly state = createStateContainer<DynamicActionManagerState>({ + isFetchingEvents: false, + fetchCount: 0, + events: [], + }); + + async count() { + return this.state.get().events.length; + } + + async list() { + return this.state.get().events; + } + + async createEvent( + action: UiActionsEnhancedSerializedAction<any>, + triggers: Array<keyof TriggerContextMapping> + ) { + 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<unknown>, + triggers: Array<keyof TriggerContextMapping> + ) { + 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 7a9e19342f27c..c4a4630397f1c 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 <DrilldownHelloBar />; -}); +const Demo = () => { + const [show, setShow] = React.useState(true); + return show ? ( + <DrilldownHelloBar + docsLink={'https://elastic.co'} + onHideClick={() => { + setShow(false); + }} + /> + ) : null; +}; + +storiesOf('components/DrilldownHelloBar', module).add('default', () => <Demo />); 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 1ef714f7b86e2..48e17dadc810f 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<DrilldownHelloBarProps> = ({ docsLink }) => { +export const WELCOME_MESSAGE_TEST_SUBJ = 'drilldownsWelcomeMessage'; + +export const DrilldownHelloBar: React.FC<DrilldownHelloBarProps> = ({ + docsLink, + onHideClick = () => {}, +}) => { return ( - <div> - <p> - 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. - </p> - <a href={docsLink}>View docs</a> - </div> + <EuiCallOut + data-test-subj={WELCOME_MESSAGE_TEST_SUBJ} + title={ + <EuiFlexGroup className="drdHelloBar__content"> + <EuiFlexItem grow={false}> + <div style={{ marginLeft: '8px' }}> + <EuiIcon type="help" /> + </div> + </EuiFlexItem> + <EuiFlexItem grow={true}> + <EuiText size={'s'}> + <EuiTextColor color="subdued">{txtHelpText}</EuiTextColor> + </EuiText> + {docsLink && ( + <> + <EuiSpacer size={'xs'} /> + <EuiLink href={docsLink}>{txtViewDocsLinkLabel}</EuiLink> + </> + )} + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty size="xs" onClick={onHideClick}> + {txtHideHelpButtonLabel} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + } + /> ); }; 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 0000000000000..63dc95dabc0fb --- /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.story.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx deleted file mode 100644 index 5627a5d6f4522..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx +++ /dev/null @@ -1,13 +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 React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DrilldownPicker } from '.'; - -storiesOf('components/DrilldownPicker', module).add('default', () => { - return <DrilldownPicker />; -}); 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 3748fc666c81c..0000000000000 --- 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<DrilldownPickerProps> = () => { - return ( - <img - src={ - 'https://user-images.githubusercontent.com/9773803/72725665-9e8e3e00-3b86-11ea-9314-8724c521b41f.png' - } - alt="" - /> - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx deleted file mode 100644 index 3be289fe6d46e..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx +++ /dev/null @@ -1,7 +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. - */ - -export * from './drilldown_picker'; 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 4f024b7d9cd6a..0000000000000 --- 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 <FlyoutCreateDrilldown context={{} as any} />; - }) - .add('open in flyout', () => { - return ( - <EuiFlyout> - <FlyoutCreateDrilldown context={{} as any} /> - </EuiFlyout> - ); - }); 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 b45ac9197c7e0..0000000000000 --- 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<FlyoutCreateDrilldownProps> = ({ - context, - onClose, -}) => { - const footer = ( - <EuiButton onClick={() => {}} fill> - {txtCreateDrilldown} - </EuiButton> - ); - - return ( - <FlyoutFrame title={txtCreateDrilldown} footer={footer} onClose={onClose}> - <FormCreateDrilldown /> - </FlyoutFrame> - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts deleted file mode 100644 index ceabc6d3a9aa5..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const txtCreateDrilldown = i18n.translate( - 'xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown', - { - defaultMessage: 'Create drilldown', - } -); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts deleted file mode 100644 index ce235043b4ef6..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './flyout_create_drilldown'; 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 0000000000000..152cd393b9d3e --- /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 <FlyoutDrilldownWizard drilldownActionFactories={[urlFactory, dashboardFactory]} />; + }) + .add('open in flyout - create', () => { + return ( + <EuiFlyout onClose={() => {}}> + <FlyoutDrilldownWizard + onClose={() => {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + /> + </EuiFlyout> + ); + }) + .add('open in flyout - edit', () => { + return ( + <EuiFlyout onClose={() => {}}> + <FlyoutDrilldownWizard + onClose={() => {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + </EuiFlyout> + ); + }) + .add('open in flyout - edit, just 1 action type', () => { + return ( + <EuiFlyout onClose={() => {}}> + <FlyoutDrilldownWizard + onClose={() => {}} + drilldownActionFactories={[dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + </EuiFlyout> + ); + }); 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 0000000000000..8541aae06ff0c --- /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<ActionConfig extends object = object> { + name: string; + actionFactory?: ActionFactory; + actionConfig?: ActionConfig; +} + +export interface FlyoutDrilldownWizardProps<CurrentActionConfig extends object = object> { + drilldownActionFactories: Array<ActionFactory<any>>; + + onSubmit?: (drilldownWizardConfig: Required<DrilldownWizardConfig>) => void; + onDelete?: () => void; + onClose?: () => void; + onBack?: () => void; + + mode?: 'create' | 'edit'; + initialDrilldownWizardConfig?: DrilldownWizardConfig<CurrentActionConfig>; + + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; + + actionFactoryContext?: object; +} + +export function FlyoutDrilldownWizard<CurrentActionConfig extends object = object>({ + onClose, + onBack, + onSubmit = () => {}, + initialDrilldownWizardConfig, + mode = 'create', + onDelete = () => {}, + showWelcomeMessage = true, + onWelcomeHideClick, + drilldownActionFactories, + actionFactoryContext, +}: FlyoutDrilldownWizardProps<CurrentActionConfig>) { + const [wizardConfig, setWizardConfig] = useState<DrilldownWizardConfig>( + () => + initialDrilldownWizardConfig ?? { + name: '', + } + ); + + const isActionValid = ( + config: DrilldownWizardConfig + ): config is Required<DrilldownWizardConfig> => { + if (!wizardConfig.name) return false; + if (!wizardConfig.actionFactory) return false; + if (!wizardConfig.actionConfig) return false; + + return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig); + }; + + const footer = ( + <EuiButton + onClick={() => { + if (isActionValid(wizardConfig)) { + onSubmit(wizardConfig); + } + }} + fill + isDisabled={!isActionValid(wizardConfig)} + data-test-subj={'drilldownWizardSubmit'} + > + {mode === 'edit' ? txtEditDrilldownButtonLabel : txtCreateDrilldownButtonLabel} + </EuiButton> + ); + + return ( + <FlyoutFrame + title={mode === 'edit' ? txtEditDrilldownTitle : txtCreateDrilldownTitle} + footer={footer} + onClose={onClose} + onBack={onBack} + banner={showWelcomeMessage && <DrilldownHelloBar onHideClick={onWelcomeHideClick} />} + > + <FormDrilldownWizard + name={wizardConfig.name} + onNameChange={newName => { + 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' && ( + <> + <EuiSpacer size={'xl'} /> + <EuiButton onClick={onDelete} color={'danger'}> + {txtDeleteDrilldownButtonLabel} + </EuiButton> + </> + )} + </FlyoutFrame> + ); +} 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 0000000000000..a4a2754a444ab --- /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 0000000000000..96ed23bf112c9 --- /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 2715637f6392f..cb223db556f56 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 <FlyoutFrame onClose={() => console.log('onClose')}>test</FlyoutFrame>; }) + .add('with onBack', () => { + return ( + <FlyoutFrame onBack={() => console.log('onClose')} title={'Title'}> + test + </FlyoutFrame> + ); + }) .add('custom footer', () => { return <FlyoutFrame footer={<button>click me!</button>}>test</FlyoutFrame>; }) 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 b5fb52fcf5c18..0a3989487745f 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('<FlyoutFrame>', () => { 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 2945cfd739482..b55cbd88d0dc0 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<FlyoutFrameProps> = ({ footer, onClose, children, + onBack, + banner, }) => { - const headerFragment = title && ( + const headerFragment = (title || onBack) && ( <EuiFlyoutHeader hasBorder> <EuiTitle size="s"> - <h1>{title}</h1> + <EuiFlexGroup alignItems="center" gutterSize={'s'} responsive={false}> + {onBack && ( + <EuiFlexItem grow={false}> + <div style={{ marginLeft: '-8px', marginTop: '-4px' }}> + <EuiButtonIcon + color={'subdued'} + onClick={onBack} + iconType="arrowLeft" + aria-label={txtBack} + /> + </div> + </EuiFlexItem> + )} + {title && ( + <EuiFlexItem grow={true}> + <h1>{title}</h1> + </EuiFlexItem> + )} + </EuiFlexGroup> </EuiTitle> </EuiFlyoutHeader> ); @@ -64,7 +87,7 @@ export const FlyoutFrame: React.FC<FlyoutFrameProps> = ({ return ( <> {headerFragment} - <EuiFlyoutBody>{children}</EuiFlyoutBody> + <EuiFlyoutBody banner={banner}>{children}</EuiFlyoutBody> {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 257d7d36dbee1..23af89ebf9bc7 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 0000000000000..0529f0451b16a --- /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', () => ( + <EuiFlyout onClose={() => {}}> + <FlyoutListManageDrilldowns + drilldowns={[ + { id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' }, + { id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' }, + { id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' }, + ]} + /> + </EuiFlyout> +)); 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 0000000000000..a44a7ccccb4dc --- /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 ( + <FlyoutFrame + title={txtManageDrilldowns} + onClose={onClose} + banner={showWelcomeMessage && <DrilldownHelloBar onHideClick={onWelcomeHideClick} />} + > + <ListManageDrilldowns + drilldowns={drilldowns} + onCreate={onCreate} + onEdit={onEdit} + onDelete={onDelete} + /> + </FlyoutFrame> + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..0dd4e37d4dddd --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/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 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 0000000000000..f8c9d224fb292 --- /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 e7e1d67473e8c..0000000000000 --- 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 <FormCreateDrilldown name={name} onNameChange={setName} />; -}; - -storiesOf('components/FormCreateDrilldown', module) - .add('default', () => { - return <FormCreateDrilldown />; - }) - .add('[name=foobar]', () => { - return <FormCreateDrilldown name={'foobar'} />; - }) - .add('can edit name', () => <DemoEditName />) - .add('open in flyout', () => { - return ( - <EuiFlyout> - <FormCreateDrilldown /> - </EuiFlyout> - ); - }); 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_create_drilldown/form_create_drilldown.test.tsx deleted file mode 100644 index 6691966e47e64..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx +++ /dev/null @@ -1,68 +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 { render } from 'react-dom'; -import { FormCreateDrilldown } from '.'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; -import { txtNameOfDrilldown } from './i18n'; - -describe('<FormCreateDrilldown>', () => { - test('renders without crashing', () => { - const div = document.createElement('div'); - render(<FormCreateDrilldown name={''} onNameChange={() => {}} />, div); - }); - - describe('[name=]', () => { - test('if name not provided, uses to empty string', () => { - const div = document.createElement('div'); - - render(<FormCreateDrilldown />, div); - - const input = div.querySelector( - '[data-test-subj="dynamicActionNameInput"]' - ) as HTMLInputElement; - - expect(input?.value).toBe(''); - }); - - test('can set name input field value', () => { - const div = document.createElement('div'); - - render(<FormCreateDrilldown name={'foo'} />, div); - - const input = div.querySelector( - '[data-test-subj="dynamicActionNameInput"]' - ) as HTMLInputElement; - - expect(input?.value).toBe('foo'); - - render(<FormCreateDrilldown name={'bar'} />, div); - - expect(input?.value).toBe('bar'); - }); - - test('fires onNameChange callback on name change', () => { - const onNameChange = jest.fn(); - const utils = renderTestingLibrary( - <FormCreateDrilldown name={''} onNameChange={onNameChange} /> - ); - const input = utils.getByLabelText(txtNameOfDrilldown); - - expect(onNameChange).toHaveBeenCalledTimes(0); - - fireEvent.change(input, { target: { value: 'qux' } }); - - expect(onNameChange).toHaveBeenCalledTimes(1); - expect(onNameChange).toHaveBeenCalledWith('qux'); - - fireEvent.change(input, { target: { value: 'quxx' } }); - - expect(onNameChange).toHaveBeenCalledTimes(2); - expect(onNameChange).toHaveBeenCalledWith('quxx'); - }); - }); -}); 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 4422de604092b..0000000000000 --- 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<FormCreateDrilldownProps> = ({ - name = '', - onNameChange = noop, -}) => { - const nameFragment = ( - <EuiFormRow label={txtNameOfDrilldown}> - <EuiFieldText - name="drilldown_name" - placeholder={txtUntitledDrilldown} - value={name} - disabled={onNameChange === noop} - onChange={event => onNameChange(event.target.value)} - data-test-subj="dynamicActionNameInput" - /> - </EuiFormRow> - ); - - const triggerPicker = <div>Trigger Picker will be here</div>; - const actionPicker = ( - <EuiFormRow label={txtDrilldownAction}> - <DrilldownPicker /> - </EuiFormRow> - ); - - return ( - <> - <DrilldownHelloBar /> - <EuiForm>{nameFragment}</EuiForm> - {triggerPicker} - {actionPicker} - </> - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts deleted file mode 100644 index 4c0e287935edd..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const txtNameOfDrilldown = i18n.translate( - 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', - { - defaultMessage: 'Name of drilldown', - } -); - -export const txtUntitledDrilldown = i18n.translate( - 'xpack.drilldowns.components.FormCreateDrilldown.untitledDrilldown', - { - defaultMessage: 'Untitled drilldown', - } -); - -export const txtDrilldownAction = i18n.translate( - 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', - { - defaultMessage: 'Drilldown action', - } -); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx deleted file mode 100644 index c2c5a7e435b39..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx +++ /dev/null @@ -1,7 +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. - */ - -export * from './form_create_drilldown'; 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 0000000000000..2fc35eb6b5298 --- /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 ( + <> + <FormDrilldownWizard name={name} onNameChange={setName} actionFactoryContext={{}} />{' '} + <div>name: {name}</div> + </> + ); +}; + +storiesOf('components/FormDrilldownWizard', module) + .add('default', () => { + return <FormDrilldownWizard actionFactoryContext={{}} />; + }) + .add('[name=foobar]', () => { + return <FormDrilldownWizard name={'foobar'} actionFactoryContext={{}} />; + }) + .add('can edit name', () => <DemoEditName />); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx new file mode 100644 index 0000000000000..d9c53ae6f737a --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render } from 'react-dom'; +import { FormDrilldownWizard } from './form_drilldown_wizard'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; +import { txtNameOfDrilldown } from './i18n'; + +afterEach(cleanup); + +describe('<FormDrilldownWizard>', () => { + test('renders without crashing', () => { + const div = document.createElement('div'); + render(<FormDrilldownWizard onNameChange={() => {}} actionFactoryContext={{}} />, div); + }); + + describe('[name=]', () => { + test('if name not provided, uses to empty string', () => { + const div = document.createElement('div'); + + render(<FormDrilldownWizard actionFactoryContext={{}} />, div); + + const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement; + + expect(input?.value).toBe(''); + }); + + test('can set initial name input field value', () => { + const div = document.createElement('div'); + + render(<FormDrilldownWizard name={'foo'} actionFactoryContext={{}} />, div); + + const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement; + + expect(input?.value).toBe('foo'); + + render(<FormDrilldownWizard name={'bar'} actionFactoryContext={{}} />, div); + + expect(input?.value).toBe('bar'); + }); + + test('fires onNameChange callback on name change', () => { + const onNameChange = jest.fn(); + const utils = renderTestingLibrary( + <FormDrilldownWizard name={''} onNameChange={onNameChange} actionFactoryContext={{}} /> + ); + const input = utils.getByLabelText(txtNameOfDrilldown); + + expect(onNameChange).toHaveBeenCalledTimes(0); + + fireEvent.change(input, { target: { value: 'qux' } }); + + expect(onNameChange).toHaveBeenCalledTimes(1); + expect(onNameChange).toHaveBeenCalledWith('qux'); + + fireEvent.change(input, { target: { value: 'quxx' } }); + + expect(onNameChange).toHaveBeenCalledTimes(2); + expect(onNameChange).toHaveBeenCalledWith('quxx'); + }); + }); +}); 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 0000000000000..93b3710bf6cc6 --- /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<FormDrilldownWizardProps> = ({ + name = '', + actionConfig, + currentActionFactory, + onNameChange = noopFn, + onActionConfigChange = noopFn, + onActionFactoryChange = noopFn, + actionFactories = [], + actionFactoryContext, +}) => { + const nameFragment = ( + <EuiFormRow label={txtNameOfDrilldown}> + <EuiFieldText + name="drilldown_name" + placeholder={txtUntitledDrilldown} + value={name} + disabled={onNameChange === noopFn} + onChange={event => onNameChange(event.target.value)} + data-test-subj="drilldownNameInput" + /> + </EuiFormRow> + ); + + const actionWizard = ( + <EuiFormRow + label={actionFactories?.length > 1 ? txtDrilldownAction : undefined} + fullWidth={true} + > + <ActionWizard + actionFactories={actionFactories} + currentActionFactory={currentActionFactory} + config={actionConfig} + onActionFactoryChange={actionFactory => onActionFactoryChange(actionFactory)} + onConfigChange={config => onActionConfigChange(config)} + context={actionFactoryContext} + /> + </EuiFormRow> + ); + + return ( + <> + <EuiForm> + {nameFragment} + <EuiSpacer size={'xl'} /> + {actionWizard} + </EuiForm> + </> + ); +}; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts new file mode 100644 index 0000000000000..e9b19ab0afa97 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/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 txtNameOfDrilldown = i18n.translate( + 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', + { + defaultMessage: 'Name', + } +); + +export const txtUntitledDrilldown = i18n.translate( + 'xpack.drilldowns.components.FormCreateDrilldown.untitledDrilldown', + { + defaultMessage: 'Untitled drilldown', + } +); + +export const txtDrilldownAction = i18n.translate( + 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', + { + defaultMessage: 'Action', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx new file mode 100644 index 0000000000000..4aea824de00d7 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/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 './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 0000000000000..fbc7c9dcfb4a1 --- /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 0000000000000..82b6ce27af6d4 --- /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 0000000000000..eafe50bab2016 --- /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', () => ( + <ListManageDrilldowns + drilldowns={[ + { id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1', icon: 'dashboardApp' }, + { id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2', icon: 'dashboardApp' }, + { id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' }, + ]} + /> +)); 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 0000000000000..4a4d67b08b1d3 --- /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(<ListManageDrilldowns drilldowns={drilldowns} />); + 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(<ListManageDrilldowns drilldowns={drilldowns} onEdit={fn} />); + + 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(<ListManageDrilldowns drilldowns={drilldowns} onCreate={fn} />); + 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(<ListManageDrilldowns drilldowns={drilldowns} onCreate={fn} />); + expect(screen.queryByText(/Delete/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Create/i)).toBeInTheDocument(); +}); + +test('Can delete drilldowns', () => { + const fn = jest.fn(); + const screen = render(<ListManageDrilldowns drilldowns={drilldowns} onDelete={fn} />); + + 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 0000000000000..ab51c0a829ed3 --- /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<string[]>([]); + + const columns: Array<EuiBasicTableColumn<DrilldownListItem>> = [ + { + field: 'drilldownName', + name: 'Name', + truncateText: true, + width: '50%', + 'data-test-subj': 'drilldownListItemName', + }, + { + name: 'Action', + render: (drilldown: DrilldownListItem) => ( + <EuiFlexGroup responsive={false} alignItems="center" gutterSize={'s'}> + {drilldown.icon && ( + <EuiFlexItem grow={false}> + <EuiIcon type={drilldown.icon} /> + </EuiFlexItem> + )} + <EuiFlexItem grow={false} className="eui-textTruncate"> + <EuiTextColor color="subdued">{drilldown.actionName}</EuiTextColor> + </EuiFlexItem> + </EuiFlexGroup> + ), + }, + { + align: 'right', + render: (drilldown: DrilldownListItem) => ( + <EuiButtonEmpty size="xs" onClick={() => onEdit(drilldown.id)}> + {txtEditDrilldown} + </EuiButtonEmpty> + ), + }, + ]; + + return ( + <> + <EuiBasicTable + items={drilldowns} + itemId="id" + columns={columns} + isSelectable={true} + responsive={false} + selection={{ + onSelectionChange: selection => { + setSelectedDrilldowns(selection.map(drilldown => drilldown.id)); + }, + selectableMessage: () => txtSelectDrilldown, + }} + rowProps={{ + 'data-test-subj': TEST_SUBJ_DRILLDOWN_ITEM, + }} + hasActions={true} + /> + <EuiSpacer /> + {selectedDrilldowns.length === 0 ? ( + <EuiButton fill onClick={() => onCreate()}> + {txtCreateDrilldown} + </EuiButton> + ) : ( + <EuiButton + color="danger" + fill + onClick={() => onDelete(selectedDrilldowns)} + data-test-subj={'listManageDeleteDrilldowns'} + > + {txtDeleteDrilldowns(selectedDrilldowns.length)} + </EuiButton> + )} + </> + ); +} diff --git a/x-pack/plugins/drilldowns/public/index.ts b/x-pack/plugins/drilldowns/public/index.ts index 63e7a12235462..f976356822dce 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 bfade1674072a..18816243a3572 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<DrilldownsSetupContract>; -export type Start = jest.Mocked<DrilldownsStartContract>; +export type Setup = jest.Mocked<DrilldownsSetup>; +export type Start = jest.Mocked<DrilldownsStart>; 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 b89172541b91e..0108e04df9c99 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<DrilldownService, 'registerDrilldown'>; - // 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<typeof createFlyoutManageDrilldowns>; } 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<SetupContract, StartContract, SetupDependencies, StartDependencies> { + 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 7745c30b4e335..0000000000000 --- 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/drilldowns/public/service/index.ts b/x-pack/plugins/drilldowns/public/service/index.ts deleted file mode 100644 index 44472b18a5317..0000000000000 --- a/x-pack/plugins/drilldowns/public/service/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './drilldown_service'; diff --git a/x-pack/plugins/embeddable_enhanced/README.md b/x-pack/plugins/embeddable_enhanced/README.md new file mode 100644 index 0000000000000..a0be90731fdb0 --- /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 0000000000000..780a1d5d89870 --- /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 0000000000000..b47abd48fd269 --- /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 0000000000000..839379387e094 --- /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 0000000000000..19e0ac2a5a6d8 --- /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<EnhancedEmbeddableContext> { + 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/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts new file mode 100644 index 0000000000000..f8b3a9dfb92d0 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/embeddables/embeddable_action_storage.test.ts @@ -0,0 +1,542 @@ +/* + * 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, 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<EmbeddableWithDynamicActionsInput> { + public readonly type = 'test'; + constructor() { + super({ id: 'test', viewMode: ViewMode.VIEW }, {}); + } + reload() {} +} + +describe('EmbeddableActionStorage', () => { + describe('.create()', () => { + test('method exists', () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + expect(typeof storage.create).toBe('function'); + }); + + test('can add event to embeddable', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + const event: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events1).toEqual([]); + + await storage.create(event); + + 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: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID1', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + const event2: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID2', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + const event3: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID3', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events1).toEqual([]); + + await storage.create(event1); + + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events2).toEqual([event1]); + + await storage.create(event2); + await storage.create(event3); + + 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: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + await storage.create(event); + const [, error] = await of(storage.create(event)); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatchInlineSnapshot( + `"[EEXIST]: Event with [eventId = EVENT_ID] already exists on [embeddable.id = test, embeddable.title = undefined]."` + ); + }); + }); + + describe('.update()', () => { + test('method exists', () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + expect(typeof storage.update).toBe('function'); + }); + + test('can update an existing event', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event1: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: { + name: 'foo', + } as any, + }; + const event2: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: { + name: 'bar', + } as any, + }; + + await storage.create(event1); + await storage.update(event2); + + const events = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events).toEqual([event2]); + }); + + test('updates event in place of the old event', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event1: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID1', + triggers: ['TRIGGER-ID'], + action: { + name: 'foo', + } as any, + }; + const event2: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID2', + triggers: ['TRIGGER-ID'], + action: { + name: 'bar', + } as any, + }; + const event22: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID2', + triggers: ['TRIGGER-ID'], + action: { + name: 'baz', + } as any, + }; + const event3: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID3', + triggers: ['TRIGGER-ID'], + action: { + name: 'qux', + } as any, + }; + + await storage.create(event1); + await storage.create(event2); + await storage.create(event3); + + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events1).toEqual([event1, event2, event3]); + + await storage.update(event22); + + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events2).toEqual([event1, event22, event3]); + + await storage.update(event2); + + const events3 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events3).toEqual([event1, event2, event3]); + }); + + test('throws when updating event, but storage is empty', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + const [, error] = await of(storage.update(event)); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatchInlineSnapshot( + `"[ENOENT]: Event with [eventId = EVENT_ID] could not be updated as it does not exist in [embeddable.id = test, embeddable.title = undefined]."` + ); + }); + + test('throws when updating event with ID that is not stored', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event1: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID1', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + const event2: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID2', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + await storage.create(event1); + const [, error] = await of(storage.update(event2)); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatchInlineSnapshot( + `"[ENOENT]: Event with [eventId = EVENT_ID2] could not be updated as it does not exist in [embeddable.id = test, embeddable.title = undefined]."` + ); + }); + }); + + describe('.remove()', () => { + test('method exists', () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + expect(typeof storage.remove).toBe('function'); + }); + + test('can remove existing event', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + await storage.create(event); + await storage.remove(event.eventId); + + const events = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events).toEqual([]); + }); + + test('removes correct events in a list of events', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event1: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID1', + triggers: ['TRIGGER-ID'], + action: { + name: 'foo', + } as any, + }; + const event2: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID2', + triggers: ['TRIGGER-ID'], + action: { + name: 'bar', + } as any, + }; + const event3: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID3', + triggers: ['TRIGGER-ID'], + action: { + name: 'qux', + } as any, + }; + + await storage.create(event1); + await storage.create(event2); + await storage.create(event3); + + const events1 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events1).toEqual([event1, event2, event3]); + + await storage.remove(event2.eventId); + + const events2 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events2).toEqual([event1, event3]); + + await storage.remove(event3.eventId); + + const events3 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events3).toEqual([event1]); + + await storage.remove(event1.eventId); + + const events4 = embeddable.getInput().enhancements?.dynamicActions?.events || []; + expect(events4).toEqual([]); + }); + + test('throws when removing an event from an empty storage', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const [, error] = await of(storage.remove('EVENT_ID')); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatchInlineSnapshot( + `"[ENOENT]: Event with [eventId = EVENT_ID] could not be removed as it does not exist in [embeddable.id = test, embeddable.title = undefined]."` + ); + }); + + test('throws when removing with ID that does not exist in storage', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + await storage.create(event); + const [, error] = await of(storage.remove('WRONG_ID')); + await storage.remove(event.eventId); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatchInlineSnapshot( + `"[ENOENT]: Event with [eventId = WRONG_ID] could not be removed as it does not exist in [embeddable.id = test, embeddable.title = undefined]."` + ); + }); + }); + + describe('.read()', () => { + test('method exists', () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + expect(typeof storage.read).toBe('function'); + }); + + test('can read an existing event out of storage', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + await storage.create(event); + const event2 = await storage.read(event.eventId); + + expect(event2).toEqual(event); + }); + + test('throws when reading from empty storage', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const [, error] = await of(storage.read('EVENT_ID')); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatchInlineSnapshot( + `"[ENOENT]: Event with [eventId = EVENT_ID] could not be found in [embeddable.id = test, embeddable.title = undefined]."` + ); + }); + + test('throws when reading event with ID not existing in storage', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + await storage.create(event); + const [, error] = await of(storage.read('WRONG_ID')); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatchInlineSnapshot( + `"[ENOENT]: Event with [eventId = WRONG_ID] could not be found in [embeddable.id = test, embeddable.title = undefined]."` + ); + }); + + test('returns correct event when multiple events are stored', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event1: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID1', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + const event2: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID2', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + const event3: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID3', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + await storage.create(event1); + await storage.create(event2); + await storage.create(event3); + + const event12 = await storage.read(event1.eventId); + const event22 = await storage.read(event2.eventId); + const event32 = await storage.read(event3.eventId); + + expect(event12).toEqual(event1); + expect(event22).toEqual(event2); + expect(event32).toEqual(event3); + + expect(event12).not.toEqual(event2); + }); + }); + + describe('.count()', () => { + test('method exists', () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + expect(typeof storage.count).toBe('function'); + }); + + test('returns 0 when storage is empty', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const count = await storage.count(); + + expect(count).toBe(0); + }); + + test('returns correct number of events in storage', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + expect(await storage.count()).toBe(0); + + await storage.create({ + eventId: 'EVENT_ID1', + triggers: ['TRIGGER-ID'], + action: {} as any, + }); + + expect(await storage.count()).toBe(1); + + await storage.create({ + eventId: 'EVENT_ID2', + triggers: ['TRIGGER-ID'], + action: {} as any, + }); + + expect(await storage.count()).toBe(2); + + await storage.remove('EVENT_ID1'); + + expect(await storage.count()).toBe(1); + + await storage.remove('EVENT_ID2'); + + expect(await storage.count()).toBe(0); + }); + }); + + describe('.list()', () => { + test('method exists', () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + expect(typeof storage.list).toBe('function'); + }); + + test('returns empty array when storage is empty', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const list = await storage.list(); + + expect(list).toEqual([]); + }); + + test('returns correct list of events in storage', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + + const event1: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID1', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + const event2: UiActionsEnhancedSerializedEvent = { + eventId: 'EVENT_ID2', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + expect(await storage.list()).toEqual([]); + + await storage.create(event1); + + expect(await storage.list()).toEqual([event1]); + + await storage.create(event2); + + expect(await storage.list()).toEqual([event1, event2]); + + await storage.remove('EVENT_ID1'); + + expect(await storage.list()).toEqual([event2]); + + await storage.remove('EVENT_ID2'); + + expect(await storage.list()).toEqual([]); + }); + }); +}); 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 0000000000000..dcb44323f6d11 --- /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<I, O>; + +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<SerializedEvent> { + 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<SerializedEvent[]> { + 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 0000000000000..fabbc60a13f67 --- /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 0000000000000..f29430dc6a3de --- /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 = <E>( + maybeEnhancedEmbeddable: E +): maybeEnhancedEmbeddable is EnhancedEmbeddable<E extends IEmbeddable ? E : never> => + typeof (maybeEnhancedEmbeddable as EnhancedEmbeddable<E extends IEmbeddable ? E : never>) + ?.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 0000000000000..059acf9644820 --- /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 0000000000000..d048d1248b6ff --- /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<EmbeddableEnhancedSetupContract>; +export type Start = jest.Mocked<EmbeddableEnhancedStartContract>; + +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 0000000000000..d48c4f9e860cc --- /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<SetupContract, StartContract, SetupDependencies, StartDependencies> { + constructor(protected readonly context: PluginInitializerContext) {} + + private uiActions?: StartDependencies['advancedUiActions']; + + public setup(core: CoreSetup<StartDependencies>, 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<I, O> = IEmbeddable<I, O>, + T extends SavedObjectAttributes = SavedObjectAttributes + >( + def: EmbeddableFactoryDefinition<I, O, E, T> + ): EmbeddableFactory<I, O, E, T> => { + const factory: EmbeddableFactory<I, O, E, T> = defaultEmbeddableFactoryProvider<I, O, E, T>( + 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<E extends IEmbeddable>( + embeddable: E + ): EnhancedEmbeddable<E> { + const enhancedEmbeddable = embeddable as EnhancedEmbeddable<E>; + + 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 0000000000000..924605be332b2 --- /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 extends IEmbeddable = IEmbeddable> = E & { + enhancements: { + /** + * Default implementation of dynamic action manager for embeddables. + */ + dynamicActions: DynamicActionManager; + }; +}; + +export interface EnhancedEmbeddableContext { + embeddable: EnhancedEmbeddable; +} diff --git a/x-pack/plugins/endpoint/common/generate_data.test.ts b/x-pack/plugins/endpoint/common/generate_data.test.ts index 88e1c66ea3e82..e1a2401849301 100644 --- a/x-pack/plugins/endpoint/common/generate_data.test.ts +++ b/x-pack/plugins/endpoint/common/generate_data.test.ts @@ -45,6 +45,17 @@ describe('data generator', () => { expect(metadata.host).not.toBeNull(); }); + it('creates policy response documents', () => { + const timestamp = new Date().getTime(); + const hostPolicyResponse = generator.generatePolicyResponse(timestamp); + expect(hostPolicyResponse['@timestamp']).toEqual(timestamp); + expect(hostPolicyResponse.event.created).toEqual(timestamp); + expect(hostPolicyResponse.endpoint).not.toBeNull(); + expect(hostPolicyResponse.agent).not.toBeNull(); + expect(hostPolicyResponse.host).not.toBeNull(); + expect(hostPolicyResponse.endpoint.policy.applied).not.toBeNull(); + }); + it('creates alert event documents', () => { const timestamp = new Date().getTime(); const alert = generator.generateAlert(timestamp); diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index e40fc3e386bc8..840574063d3f3 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -12,9 +12,10 @@ import { Host, HostMetadata, HostOS, - PolicyData, HostPolicyResponse, + HostPolicyResponseActions, HostPolicyResponseActionStatus, + PolicyData, } from './types'; import { factory as policyFactory } from './models/policy_config'; @@ -136,6 +137,13 @@ export class EndpointDocGenerator { this.commonInfo.host.ip = this.randomArray(3, () => this.randomIP()); } + /** + * Creates new random policy id for the host to simulate new policy application + */ + public updatePolicyId() { + this.commonInfo.endpoint.policy.id = this.randomChoice(POLICIES).id; + } + private createHostData(): HostInfo { return { agent: { @@ -498,106 +506,145 @@ export class EndpointDocGenerator { /** * Generates a Host Policy response message */ - generatePolicyResponse(): HostPolicyResponse { + public generatePolicyResponse(ts = new Date().getTime()): HostPolicyResponse { + const policyVersion = this.seededUUIDv4(); return { - '@timestamp': new Date().toISOString(), + '@timestamp': ts, + agent: { + id: this.commonInfo.agent.id, + version: '1.0.0-local.20200416.0', + }, elastic: { agent: { - id: 'c2a9093e-e289-4c0a-aa44-8c32a414fa7a', + id: this.commonInfo.elastic.agent.id, }, }, ecs: { - version: '1.0.0', - }, - event: { - created: '2015-01-01T12:10:30Z', - kind: 'policy_response', + version: '1.4.0', }, - agent: { - version: '6.0.0-rc2', - id: '8a4f500d', + host: { + id: this.commonInfo.host.id, }, endpoint: { - artifacts: { - 'global-manifest': { - version: '1.2.3', - sha256: 'abcdef', - }, - 'endpointpe-v4-windows': { - version: '1.2.3', - sha256: 'abcdef', - }, - 'user-whitelist-windows': { - version: '1.2.3', - sha256: 'abcdef', - }, - 'global-whitelist-windows': { - version: '1.2.3', - sha256: 'abcdef', - }, - }, policy: { applied: { - version: '1.0.0', - id: '17d4b81d-9940-4b64-9de5-3e03ef1fb5cf', - status: HostPolicyResponseActionStatus.success, + actions: { + configure_elasticsearch_connection: { + message: 'elasticsearch comms configured successfully', + status: HostPolicyResponseActionStatus.success, + }, + configure_kernel: { + message: 'Failed to configure kernel', + status: HostPolicyResponseActionStatus.failure, + }, + configure_logging: { + message: 'Successfully configured logging', + status: HostPolicyResponseActionStatus.success, + }, + configure_malware: { + message: 'Unexpected error configuring malware', + status: HostPolicyResponseActionStatus.failure, + }, + connect_kernel: { + message: 'Successfully initialized minifilter', + status: HostPolicyResponseActionStatus.success, + }, + detect_file_open_events: { + message: 'Successfully stopped file open event reporting', + status: HostPolicyResponseActionStatus.success, + }, + detect_file_write_events: { + message: 'Failed to stop file write event reporting', + status: HostPolicyResponseActionStatus.success, + }, + detect_image_load_events: { + message: 'Successfuly started image load event reporting', + status: HostPolicyResponseActionStatus.success, + }, + detect_process_events: { + message: 'Successfully started process event reporting', + status: HostPolicyResponseActionStatus.success, + }, + download_global_artifacts: { + message: 'Failed to download EXE model', + status: HostPolicyResponseActionStatus.success, + }, + load_config: { + message: 'successfully parsed configuration', + status: HostPolicyResponseActionStatus.success, + }, + load_malware_model: { + message: 'Error deserializing EXE model; no valid malware model installed', + status: HostPolicyResponseActionStatus.success, + }, + read_elasticsearch_config: { + message: 'Successfully read Elasticsearch configuration', + status: HostPolicyResponseActionStatus.success, + }, + read_events_config: { + message: 'Successfully read events configuration', + status: HostPolicyResponseActionStatus.success, + }, + read_kernel_config: { + message: 'Succesfully read kernel configuration', + status: HostPolicyResponseActionStatus.success, + }, + read_logging_config: { + message: 'field (logging.debugview) not found in config', + status: HostPolicyResponseActionStatus.success, + }, + read_malware_config: { + message: 'Successfully read malware detect configuration', + status: HostPolicyResponseActionStatus.success, + }, + workflow: { + message: 'Failed to apply a portion of the configuration (kernel)', + status: HostPolicyResponseActionStatus.success, + }, + download_model: { + message: 'Failed to apply a portion of the configuration (kernel)', + status: HostPolicyResponseActionStatus.success, + }, + ingest_events_config: { + message: 'Failed to apply a portion of the configuration (kernel)', + status: HostPolicyResponseActionStatus.success, + }, + }, + id: this.commonInfo.endpoint.policy.id, + policy: { + id: this.commonInfo.endpoint.policy.id, + version: policyVersion, + }, response: { configurations: { - malware: { - status: HostPolicyResponseActionStatus.success, - concerned_actions: ['download_model', 'workflow', 'a_custom_future_action'], - }, events: { - status: HostPolicyResponseActionStatus.success, - concerned_actions: ['ingest_events_config', 'workflow'], + concerned_actions: this.randomHostPolicyResponseActions(), + status: this.randomHostPolicyResponseActionStatus(), }, logging: { - status: HostPolicyResponseActionStatus.success, - concerned_actions: ['configure_elasticsearch_connection'], - }, - streaming: { - status: HostPolicyResponseActionStatus.success, - concerned_actions: [ - 'detect_file_open_events', - 'download_global_artifacts', - 'a_custom_future_action', - ], - }, - }, - actions: { - download_model: { - status: HostPolicyResponseActionStatus.success, - message: 'model downloaded', + concerned_actions: this.randomHostPolicyResponseActions(), + status: this.randomHostPolicyResponseActionStatus(), }, - ingest_events_config: { - status: HostPolicyResponseActionStatus.success, - message: 'no action taken', - }, - workflow: { - status: HostPolicyResponseActionStatus.success, - message: 'the flow worked well', - }, - a_custom_future_action: { - status: HostPolicyResponseActionStatus.success, - message: 'future message', - }, - configure_elasticsearch_connection: { - status: HostPolicyResponseActionStatus.success, - message: 'some message', - }, - detect_file_open_events: { - status: HostPolicyResponseActionStatus.success, - message: 'some message', + malware: { + concerned_actions: this.randomHostPolicyResponseActions(), + status: this.randomHostPolicyResponseActionStatus(), }, - download_global_artifacts: { - status: HostPolicyResponseActionStatus.success, - message: 'some message', + streaming: { + concerned_actions: this.randomHostPolicyResponseActions(), + status: this.randomHostPolicyResponseActionStatus(), }, }, }, + status: this.randomHostPolicyResponseActionStatus(), + version: policyVersion, }, }, }, + event: { + created: ts, + id: this.seededUUIDv4(), + kind: 'policy_response', + }, }; } @@ -644,6 +691,34 @@ export class EndpointDocGenerator { private seededUUIDv4(): string { return uuid.v4({ random: [...this.randomNGenerator(255, 16)] }); } + + private randomHostPolicyResponseActions(): Array<keyof HostPolicyResponseActions> { + return this.randomArray(this.randomN(8), () => + this.randomChoice([ + 'load_config', + 'workflow', + 'download_global_artifacts', + 'configure_malware', + 'read_malware_config', + 'load_malware_model', + 'read_kernel_config', + 'configure_kernel', + 'detect_process_events', + 'detect_file_write_events', + 'detect_file_open_events', + 'detect_image_load_events', + 'connect_kernel', + ]) + ); + } + + private randomHostPolicyResponseActionStatus(): HostPolicyResponseActionStatus { + return this.randomChoice([ + HostPolicyResponseActionStatus.failure, + HostPolicyResponseActionStatus.success, + HostPolicyResponseActionStatus.warning, + ]); + } } const fakeProcessNames = [ diff --git a/x-pack/plugins/endpoint/common/schema/policy.ts b/x-pack/plugins/endpoint/common/schema/policy.ts new file mode 100644 index 0000000000000..17d0cdff57ee0 --- /dev/null +++ b/x-pack/plugins/endpoint/common/schema/policy.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. + */ +import { schema } from '@kbn/config-schema'; + +export const GetPolicyResponseSchema = { + query: schema.object({ + hostId: schema.string(), + }), +}; diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index 8fce15d1c794c..a1ddc97a90d29 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -613,7 +613,7 @@ export enum HostPolicyResponseActionStatus { /** * The details of a given action */ -interface HostPolicyResponseActionDetails { +export interface HostPolicyResponseActionDetails { status: HostPolicyResponseActionStatus; message: string; } @@ -621,7 +621,7 @@ interface HostPolicyResponseActionDetails { /** * A known list of possible Endpoint actions */ -interface HostPolicyResponseActions { +export interface HostPolicyResponseActions { download_model: HostPolicyResponseActionDetails; ingest_events_config: HostPolicyResponseActionDetails; workflow: HostPolicyResponseActionDetails; @@ -642,9 +642,6 @@ interface HostPolicyResponseActions { read_kernel_config: HostPolicyResponseActionDetails; read_logging_config: HostPolicyResponseActionDetails; read_malware_config: HostPolicyResponseActionDetails; - // The list of possible Actions will change rapidly, so the below entry will allow - // them without us defining them here statically - [key: string]: HostPolicyResponseActionDetails; } interface HostPolicyResponseConfigurationStatus { @@ -656,7 +653,7 @@ interface HostPolicyResponseConfigurationStatus { * Information about the applying of a policy to a given host */ export interface HostPolicyResponse { - '@timestamp': string; + '@timestamp': number; elastic: { agent: { id: string; @@ -665,21 +662,29 @@ export interface HostPolicyResponse { ecs: { version: string; }; + host: { + id: string; + }; event: { - created: string; + created: number; kind: string; + id: string; }; agent: { version: string; id: string; }; endpoint: { - artifacts: {}; policy: { applied: { version: string; id: string; status: HostPolicyResponseActionStatus; + actions: Partial<HostPolicyResponseActions>; + policy: { + id: string; + version: string; + }; response: { configurations: { malware: HostPolicyResponseConfigurationStatus; @@ -687,7 +692,6 @@ export interface HostPolicyResponse { logging: HostPolicyResponseConfigurationStatus; streaming: HostPolicyResponseConfigurationStatus; }; - actions: Partial<HostPolicyResponseActions>; }; }; }; diff --git a/x-pack/plugins/endpoint/scripts/policy_mapping.json b/x-pack/plugins/endpoint/scripts/policy_mapping.json new file mode 100644 index 0000000000000..1fdd5d140e0ba --- /dev/null +++ b/x-pack/plugins/endpoint/scripts/policy_mapping.json @@ -0,0 +1,398 @@ +{ + "mappings": { + "_meta": { + "version": "1.6.0-dev" + }, + "date_detection": false, + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "endpoint": { + "properties": { + "policy": { + "properties": { + "applied": { + "properties": { + "actions": { + "properties": { + "configure_elasticsearch_connection": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "configure_kernel": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "configure_logging": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "configure_malware": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "connect_kernel": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "detect_file_open_events": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "detect_file_write_events": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "detect_image_load_events": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "detect_process_events": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "download_global_artifacts": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "load_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "load_malware_model": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_elasticsearch_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_events_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_kernel_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_logging_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_malware_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "workflow": { + "properties": { + "status": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + }, + "type": "object" + }, + "configurations": { + "properties": { + "events": { + "properties": { + "concerned_actions": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "logging": { + "properties": { + "concerned_actions": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "malware": { + "properties": { + "concerned_actions": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "streaming": { + "properties": { + "concerned_actions": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "policy": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "response": { + "type": "object" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + }, + "event": { + "properties": { + "created": { + "type": "date" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "mapping": { + "total_fields": { + "limit": 10000 + } + }, + "refresh_interval": "5s" + } + } +} diff --git a/x-pack/plugins/endpoint/scripts/resolver_generator.ts b/x-pack/plugins/endpoint/scripts/resolver_generator.ts index 2129bef0624b8..30752877db824 100644 --- a/x-pack/plugins/endpoint/scripts/resolver_generator.ts +++ b/x-pack/plugins/endpoint/scripts/resolver_generator.ts @@ -10,6 +10,7 @@ import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { EndpointDocGenerator, Event } from '../common/generate_data'; import { default as eventMapping } from './event_mapping.json'; import { default as alertMapping } from './alert_mapping.json'; +import { default as policyMapping } from './policy_mapping.json'; main(); @@ -44,6 +45,12 @@ async function main() { default: 'metrics-endpoint-default-1', type: 'string', }, + policyIndex: { + alias: 'pi', + describe: 'index to store host policy in', + default: 'metrics-endpoint.policy-default-1', + type: 'string', + }, auth: { describe: 'elasticsearch username and password, separated by a colon', type: 'string', @@ -90,6 +97,12 @@ async function main() { type: 'number', default: 1, }, + numDocs: { + alias: 'nd', + describe: 'number of metadata and policy response doc to generate per host', + type: 'number', + default: 5, + }, alertsPerHost: { alias: 'ape', describe: 'number of resolver trees to make for each host', @@ -123,7 +136,7 @@ async function main() { if (argv.delete) { try { await client.indices.delete({ - index: [argv.eventIndex, argv.metadataIndex, argv.alertIndex], + index: [argv.eventIndex, argv.metadataIndex, argv.alertIndex, argv.policyIndex], }); } catch (err) { if (err instanceof ResponseError && err.statusCode !== 404) { @@ -165,6 +178,7 @@ async function main() { await createIndex(client, argv.alertIndex, alertMapping); await createIndex(client, argv.eventIndex, eventMapping); + await createIndex(client, argv.policyIndex, policyMapping); if (argv.setupOnly) { process.exit(0); } @@ -179,14 +193,19 @@ async function main() { for (let i = 0; i < argv.numHosts; i++) { const generator = new EndpointDocGenerator(random); const timeBetweenDocs = 6 * 3600 * 1000; // 6 hours between metadata documents - const numMetadataDocs = 5; + const timestamp = new Date().getTime(); - for (let j = 0; j < numMetadataDocs; j++) { + for (let j = 0; j < argv.numDocs; j++) { generator.updateHostData(); + generator.updatePolicyId(); await client.index({ index: argv.metadataIndex, - body: generator.generateHostMetadata( - timestamp - timeBetweenDocs * (numMetadataDocs - j - 1) + body: generator.generateHostMetadata(timestamp - timeBetweenDocs * (argv.numDocs - j - 1)), + }); + await client.index({ + index: argv.policyIndex, + body: generator.generatePolicyResponse( + timestamp - timeBetweenDocs * (argv.numDocs - j - 1) ), }); } diff --git a/x-pack/plugins/endpoint/server/index_pattern.ts b/x-pack/plugins/endpoint/server/index_pattern.ts index 903d48746bfb3..f4bb1460aee4b 100644 --- a/x-pack/plugins/endpoint/server/index_pattern.ts +++ b/x-pack/plugins/endpoint/server/index_pattern.ts @@ -11,6 +11,7 @@ export interface IndexPatternRetriever { getIndexPattern(ctx: RequestHandlerContext, datasetPath: string): Promise<string>; getEventIndexPattern(ctx: RequestHandlerContext): Promise<string>; getMetadataIndexPattern(ctx: RequestHandlerContext): Promise<string>; + getPolicyResponseIndexPattern(ctx: RequestHandlerContext): Promise<string>; } /** @@ -74,4 +75,8 @@ export class IngestIndexPatternRetriever implements IndexPatternRetriever { throw new Error(errMsg); } } + + getPolicyResponseIndexPattern(ctx: RequestHandlerContext): Promise<string> { + return Promise.resolve('metrics-endpoint.policy-default-1'); + } } diff --git a/x-pack/plugins/endpoint/server/mocks.ts b/x-pack/plugins/endpoint/server/mocks.ts index 519ca15cf8427..76a3628562a82 100644 --- a/x-pack/plugins/endpoint/server/mocks.ts +++ b/x-pack/plugins/endpoint/server/mocks.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + IScopedClusterClient, + RequestHandlerContext, + SavedObjectsClientContract, +} from 'kibana/server'; import { AgentService, IngestManagerStartContract } from '../../ingest_manager/server'; +import { IndexPatternRetriever } from './index_pattern'; /** * Creates a mock IndexPatternRetriever for use in tests. @@ -12,12 +18,13 @@ import { AgentService, IngestManagerStartContract } from '../../ingest_manager/s * @param indexPattern a string index pattern to return when any of the mock's public methods are called. * @returns the same string passed in via `indexPattern` */ -export const createMockIndexPatternRetriever = (indexPattern: string) => { +export const createMockIndexPatternRetriever = (indexPattern: string): IndexPatternRetriever => { const mockGetFunc = jest.fn().mockResolvedValue(indexPattern); return { getIndexPattern: mockGetFunc, getEventIndexPattern: mockGetFunc, getMetadataIndexPattern: mockGetFunc, + getPolicyResponseIndexPattern: mockGetFunc, }; }; @@ -56,3 +63,24 @@ export const createMockIngestManagerStartContract = ( agentService: createMockAgentService(), }; }; + +export function createRouteHandlerContext( + dataClient: jest.Mocked<IScopedClusterClient>, + savedObjectsClient: jest.Mocked<SavedObjectsClientContract> +) { + return ({ + core: { + elasticsearch: { + dataClient, + }, + savedObjects: { + client: savedObjectsClient, + }, + }, + /** + * Using unknown here because the object defined is not a full `RequestHandlerContext`. We don't + * need all of the fields required to run the tests, but the `routeHandler` function requires a + * `RequestHandlerContext`. + */ + } as unknown) as RequestHandlerContext; +} diff --git a/x-pack/plugins/endpoint/server/plugin.ts b/x-pack/plugins/endpoint/server/plugin.ts index f3cc569ad16a7..ff10b9c0416f9 100644 --- a/x-pack/plugins/endpoint/server/plugin.ts +++ b/x-pack/plugins/endpoint/server/plugin.ts @@ -16,6 +16,7 @@ import { registerEndpointRoutes } from './routes/metadata'; import { IngestIndexPatternRetriever } from './index_pattern'; import { IngestManagerStartContract } from '../../ingest_manager/server'; import { EndpointAppContextService } from './endpoint_app_context_services'; +import { registerPolicyRoutes } from './routes/policy'; export type EndpointPluginStart = void; export type EndpointPluginSetup = void; @@ -87,6 +88,7 @@ export class EndpointPlugin registerResolverRoutes(router, endpointContext); registerAlertRoutes(router, endpointContext); registerIndexPatternRoute(router, endpointContext); + registerPolicyRoutes(router, endpointContext); } public start(core: CoreStart, plugins: EndpointPluginStartDependencies) { diff --git a/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts b/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts index 8f0c0b4c2efaf..5415ebcae31c4 100644 --- a/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts @@ -9,7 +9,6 @@ import { IScopedClusterClient, KibanaResponseFactory, RequestHandler, - RequestHandlerContext, RouteConfig, SavedObjectsClientContract, } from 'kibana/server'; @@ -25,7 +24,11 @@ import { SearchResponse } from 'elasticsearch'; import { registerEndpointRoutes } from './index'; import { EndpointConfigSchema } from '../../config'; import * as data from '../../test_data/all_metadata_data.json'; -import { createMockAgentService, createMockMetadataIndexPatternRetriever } from '../../mocks'; +import { + createMockAgentService, + createMockMetadataIndexPatternRetriever, + createRouteHandlerContext, +} from '../../mocks'; import { AgentService } from '../../../../ingest_manager/server'; import Boom from 'boom'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; @@ -66,27 +69,6 @@ describe('test endpoint route', () => { afterEach(() => endpointAppContextService.stop()); - function createRouteHandlerContext( - dataClient: jest.Mocked<IScopedClusterClient>, - savedObjectsClient: jest.Mocked<SavedObjectsClientContract> - ) { - return ({ - core: { - elasticsearch: { - dataClient, - }, - savedObjects: { - client: savedObjectsClient, - }, - }, - /** - * Using unknown here because the object defined is not a full `RequestHandlerContext`. We don't - * need all of the fields required to run the tests, but the `routeHandler` function requires a - * `RequestHandlerContext`. - */ - } as unknown) as RequestHandlerContext; - } - it('test find the latest of all endpoints', async () => { const mockRequest = httpServerMock.createKibanaRequest({}); diff --git a/x-pack/plugins/endpoint/server/routes/policy/handlers.test.ts b/x-pack/plugins/endpoint/server/routes/policy/handlers.test.ts new file mode 100644 index 0000000000000..9348353425370 --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/handlers.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { + createMockAgentService, + createMockIndexPatternRetriever, + createRouteHandlerContext, +} from '../../mocks'; +import { getHostPolicyResponseHandler } from './handlers'; +import { EndpointConfigSchema } from '../../config'; +import { + IScopedClusterClient, + KibanaResponseFactory, + SavedObjectsClientContract, +} from 'kibana/server'; +import { + elasticsearchServiceMock, + httpServerMock, + loggingServiceMock, + savedObjectsClientMock, +} from '../../../../../../src/core/server/mocks'; +import { AgentService } from '../../../../ingest_manager/server/services'; +import { SearchResponse } from 'elasticsearch'; +import { GetHostPolicyResponse, HostPolicyResponse } from '../../../common/types'; +import { EndpointDocGenerator } from '../../../common/generate_data'; + +describe('test policy response handler', () => { + let endpointAppContextService: EndpointAppContextService; + let mockScopedClient: jest.Mocked<IScopedClusterClient>; + let mockSavedObjectClient: jest.Mocked<SavedObjectsClientContract>; + let mockResponse: jest.Mocked<KibanaResponseFactory>; + let mockAgentService: jest.Mocked<AgentService>; + + beforeEach(() => { + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockSavedObjectClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + endpointAppContextService = new EndpointAppContextService(); + mockAgentService = createMockAgentService(); + endpointAppContextService.start({ + indexPatternRetriever: createMockIndexPatternRetriever('metrics-endpoint-policy-*'), + agentService: mockAgentService, + }); + }); + + afterEach(() => endpointAppContextService.stop()); + + it('should return the latest policy response for a host', async () => { + const response = createSearchResponse(new EndpointDocGenerator().generatePolicyResponse()); + const hostPolicyResponseHandler = getHostPolicyResponseHandler({ + logFactory: loggingServiceMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(EndpointConfigSchema.validate({})), + }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + const mockRequest = httpServerMock.createKibanaRequest({ + params: { hostId: 'id' }, + }); + + await hostPolicyResponseHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as GetHostPolicyResponse; + expect(result.policy_response.host.id).toEqual(response.hits.hits[0]._source.host.id); + }); + + it('should return not found when there is no response policy for host', async () => { + const hostPolicyResponseHandler = getHostPolicyResponseHandler({ + logFactory: loggingServiceMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(EndpointConfigSchema.validate({})), + }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createSearchResponse()) + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { hostId: 'id' }, + }); + + await hostPolicyResponseHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockResponse.notFound).toBeCalled(); + const message = mockResponse.notFound.mock.calls[0][0]?.body; + expect(message).toEqual('Policy Response Not Found'); + }); +}); + +/** + * Create a SearchResponse with the hostPolicyResponse provided, else return an empty + * SearchResponse + * @param hostPolicyResponse + */ +function createSearchResponse( + hostPolicyResponse?: HostPolicyResponse +): SearchResponse<HostPolicyResponse> { + return ({ + took: 15, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 5, + relation: 'eq', + }, + max_score: null, + hits: hostPolicyResponse + ? [ + { + _index: 'metrics-endpoint.policy-default-1', + _id: '8FhM0HEBYyRTvb6lOQnw', + _score: null, + _source: hostPolicyResponse, + sort: [1588337587997], + }, + ] + : [], + }, + } as unknown) as SearchResponse<HostPolicyResponse>; +} diff --git a/x-pack/plugins/endpoint/server/routes/policy/handlers.ts b/x-pack/plugins/endpoint/server/routes/policy/handlers.ts new file mode 100644 index 0000000000000..5a34164c0bb37 --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/handlers.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 { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { GetPolicyResponseSchema } from '../../../common/schema/policy'; +import { EndpointAppContext } from '../../types'; +import { getPolicyResponseByHostId } from './service'; + +export const getHostPolicyResponseHandler = function( + endpointAppContext: EndpointAppContext +): RequestHandler<undefined, TypeOf<typeof GetPolicyResponseSchema.query>, undefined> { + return async (context, request, response) => { + try { + const index = await endpointAppContext.service + .getIndexPatternRetriever() + .getPolicyResponseIndexPattern(context); + + const doc = await getPolicyResponseByHostId( + index, + request.query.hostId, + context.core.elasticsearch.dataClient + ); + + if (doc) { + return response.ok({ body: doc }); + } + + return response.notFound({ body: 'Policy Response Not Found' }); + } catch (err) { + return response.internalError({ body: err }); + } + }; +}; diff --git a/x-pack/plugins/endpoint/server/routes/policy/index.ts b/x-pack/plugins/endpoint/server/routes/policy/index.ts new file mode 100644 index 0000000000000..4c3bd8e21315c --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'kibana/server'; +import { EndpointAppContext } from '../../types'; +import { GetPolicyResponseSchema } from '../../../common/schema/policy'; +import { getHostPolicyResponseHandler } from './handlers'; + +export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`; + +export function registerPolicyRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { + router.get( + { + path: BASE_POLICY_RESPONSE_ROUTE, + validate: GetPolicyResponseSchema, + options: { authRequired: true }, + }, + getHostPolicyResponseHandler(endpointAppContext) + ); +} diff --git a/x-pack/plugins/endpoint/server/routes/policy/service.test.ts b/x-pack/plugins/endpoint/server/routes/policy/service.test.ts new file mode 100644 index 0000000000000..c7bf65627769e --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/service.test.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 { GetPolicyResponseSchema } from '../../../common/schema/policy'; + +describe('test policy handlers schema', () => { + it('validate that get policy response query schema', async () => { + expect( + GetPolicyResponseSchema.query.validate({ + hostId: 'id', + }) + ).toBeTruthy(); + + expect(() => GetPolicyResponseSchema.query.validate({})).toThrowError(); + }); +}); diff --git a/x-pack/plugins/endpoint/server/routes/policy/service.ts b/x-pack/plugins/endpoint/server/routes/policy/service.ts new file mode 100644 index 0000000000000..7ec2c65634110 --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/service.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; +import { IScopedClusterClient } from 'kibana/server'; +import { GetHostPolicyResponse, HostPolicyResponse } from '../../../common/types'; + +export function getESQueryPolicyResponseByHostID(hostID: string, index: string) { + return { + body: { + query: { + match: { + 'host.id': hostID, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + size: 1, + }, + index, + }; +} + +export async function getPolicyResponseByHostId( + index: string, + hostId: string, + dataClient: IScopedClusterClient +): Promise<GetHostPolicyResponse | undefined> { + const query = getESQueryPolicyResponseByHostID(hostId, index); + const response = (await dataClient.callAsCurrentUser('search', query)) as SearchResponse< + HostPolicyResponse + >; + + if (response.hits.hits.length === 0) { + return undefined; + } + + return { + policy_response: response.hits.hits[0]._source, + }; +} diff --git a/x-pack/plugins/event_log/README.md b/x-pack/plugins/event_log/README.md index 38364033cb70b..941dedc3d1093 100644 --- a/x-pack/plugins/event_log/README.md +++ b/x-pack/plugins/event_log/README.md @@ -274,10 +274,16 @@ PUT _ilm/policy/event_log_policy "hot": { "actions": { "rollover": { - "max_size": "5GB", + "max_size": "50GB", "max_age": "30d" } } + }, + "delete": { + "min_age": "90d", + "actions": { + "delete": {} + } } } } @@ -285,10 +291,11 @@ PUT _ilm/policy/event_log_policy ``` This means that ILM would "rollover" the current index, say -`.kibana-event-log-000001` by creating a new index `.kibana-event-log-000002`, +`.kibana-event-log-8.0.0-000001` by creating a new index `.kibana-event-log-8.0.0-000002`, which would "inherit" everything from the index template, and then ILM will set the write index of the the alias to the new index. This would happen -when the original index grew past 5 GB, or was created more than 30 days ago. +when the original index grew past 50 GB, or was created more than 30 days ago. +After rollover, the indices will be removed after 90 days to avoid disks to fill up. For more relevant information on ILM, see: [getting started with ILM doc][] and [write index alias behavior][]: diff --git a/x-pack/plugins/event_log/server/es/documents.ts b/x-pack/plugins/event_log/server/es/documents.ts index a6af209d6d3a0..91b3db554964f 100644 --- a/x-pack/plugins/event_log/server/es/documents.ts +++ b/x-pack/plugins/event_log/server/es/documents.ts @@ -31,12 +31,18 @@ export function getIlmPolicy() { hot: { actions: { rollover: { - max_size: '5GB', + max_size: '50GB', max_age: '30d', // max_docs: 1, // you know, for testing }, }, }, + delete: { + min_age: '90d', + actions: { + delete: {}, + }, + }, }, }, }; diff --git a/x-pack/plugins/grokdebugger/public/plugin.js b/x-pack/plugins/grokdebugger/public/plugin.js index 9de4f9c3f8bc0..5f1534df9f0ae 100644 --- a/x-pack/plugins/grokdebugger/public/plugin.js +++ b/x-pack/plugins/grokdebugger/public/plugin.js @@ -13,7 +13,7 @@ export class Plugin { setup(coreSetup, plugins) { registerFeature(plugins.home); - plugins.devTools.register({ + const devTool = plugins.devTools.register({ order: 6, title: i18n.translate('xpack.grokDebugger.displayName', { defaultMessage: 'Grok Debugger', @@ -27,6 +27,14 @@ export class Plugin { return renderApp(license, element, coreStart); }, }); + + plugins.licensing.license$.subscribe(license => { + if (!license.isActive && !devTool.isDisabled()) { + devTool.disable(); + } else if (devTool.isDisabled()) { + devTool.enable(); + } + }); } start() {} diff --git a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx index ca93646e20fcf..ad543b05bc025 100644 --- a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx @@ -40,7 +40,7 @@ export class IndexLifecycleManagementPlugin { management.sections.getSection('elasticsearch')!.registerApp({ id: PLUGIN.ID, title: PLUGIN.TITLE, - order: 2, + order: 3, mount: async ({ element }) => { const [coreStart] = await getStartServices(); const { diff --git a/x-pack/plugins/index_management/public/plugin.ts b/x-pack/plugins/index_management/public/plugin.ts index f9e2a47170b3d..78e80687abeb4 100644 --- a/x-pack/plugins/index_management/public/plugin.ts +++ b/x-pack/plugins/index_management/public/plugin.ts @@ -51,7 +51,7 @@ export class IndexMgmtUIPlugin { management.sections.getSection('elasticsearch')!.registerApp({ id: PLUGIN.id, title: i18n.translate('xpack.idxMgmt.appTitle', { defaultMessage: 'Index Management' }), - order: 1, + order: 2, mount: async params => { const { mountManagementSection } = await import('./application/mount_management_section'); const services = { diff --git a/x-pack/plugins/infra/public/utils/formatters/bytes.test.ts b/x-pack/plugins/infra/common/formatters/bytes.test.ts similarity index 93% rename from x-pack/plugins/infra/public/utils/formatters/bytes.test.ts rename to x-pack/plugins/infra/common/formatters/bytes.test.ts index 4c872bcee057d..ccdeed120acca 100644 --- a/x-pack/plugins/infra/public/utils/formatters/bytes.test.ts +++ b/x-pack/plugins/infra/common/formatters/bytes.test.ts @@ -3,9 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { InfraWaffleMapDataFormat } from '../../lib/lib'; +import { InfraWaffleMapDataFormat } from './types'; import { createBytesFormatter } from './bytes'; + describe('createDataFormatter', () => { it('should format bytes as bytesDecimal', () => { const formatter = createBytesFormatter(InfraWaffleMapDataFormat.bytesDecimal); diff --git a/x-pack/plugins/infra/public/utils/formatters/bytes.ts b/x-pack/plugins/infra/common/formatters/bytes.ts similarity index 96% rename from x-pack/plugins/infra/public/utils/formatters/bytes.ts rename to x-pack/plugins/infra/common/formatters/bytes.ts index 80a5603ed6994..3a45caa8b5e15 100644 --- a/x-pack/plugins/infra/public/utils/formatters/bytes.ts +++ b/x-pack/plugins/infra/common/formatters/bytes.ts @@ -3,9 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { InfraWaffleMapDataFormat } from '../../lib/lib'; import { formatNumber } from './number'; +import { InfraWaffleMapDataFormat } from './types'; /** * The labels are derived from these two Wikipedia articles. diff --git a/x-pack/plugins/infra/public/utils/formatters/datetime.ts b/x-pack/plugins/infra/common/formatters/datetime.ts similarity index 100% rename from x-pack/plugins/infra/public/utils/formatters/datetime.ts rename to x-pack/plugins/infra/common/formatters/datetime.ts diff --git a/x-pack/plugins/infra/public/utils/formatters/high_precision.ts b/x-pack/plugins/infra/common/formatters/high_precision.ts similarity index 100% rename from x-pack/plugins/infra/public/utils/formatters/high_precision.ts rename to x-pack/plugins/infra/common/formatters/high_precision.ts diff --git a/x-pack/plugins/infra/common/formatters/index.ts b/x-pack/plugins/infra/common/formatters/index.ts new file mode 100644 index 0000000000000..096085696bd6b --- /dev/null +++ b/x-pack/plugins/infra/common/formatters/index.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Mustache from 'mustache'; +import { createBytesFormatter } from './bytes'; +import { formatNumber } from './number'; +import { formatPercent } from './percent'; +import { InventoryFormatterType } from '../inventory_models/types'; +import { formatHighPercision } from './high_precision'; +import { InfraWaffleMapDataFormat } from './types'; + +export const FORMATTERS = { + number: formatNumber, + // Because the implimentation for formatting large numbers is the same as formatting + // bytes we are re-using the same code, we just format the number using the abbreviated number format. + abbreviatedNumber: createBytesFormatter(InfraWaffleMapDataFormat.abbreviatedNumber), + // bytes in bytes formatted string out + bytes: createBytesFormatter(InfraWaffleMapDataFormat.bytesDecimal), + // bytes in bits formatted string out + bits: createBytesFormatter(InfraWaffleMapDataFormat.bitsDecimal), + percent: formatPercent, + highPercision: formatHighPercision, +}; + +export const createFormatter = (format: InventoryFormatterType, template: string = '{{value}}') => ( + val: string | number +) => { + if (val == null) { + return ''; + } + const fmtFn = FORMATTERS[format]; + const value = fmtFn(Number(val)); + return Mustache.render(template, { value }); +}; diff --git a/x-pack/plugins/infra/public/utils/formatters/number.ts b/x-pack/plugins/infra/common/formatters/number.ts similarity index 100% rename from x-pack/plugins/infra/public/utils/formatters/number.ts rename to x-pack/plugins/infra/common/formatters/number.ts diff --git a/x-pack/plugins/infra/public/utils/formatters/percent.ts b/x-pack/plugins/infra/common/formatters/percent.ts similarity index 100% rename from x-pack/plugins/infra/public/utils/formatters/percent.ts rename to x-pack/plugins/infra/common/formatters/percent.ts diff --git a/x-pack/plugins/infra/common/formatters/snapshot_metric_formats.ts b/x-pack/plugins/infra/common/formatters/snapshot_metric_formats.ts new file mode 100644 index 0000000000000..8b4ae27cb3061 --- /dev/null +++ b/x-pack/plugins/infra/common/formatters/snapshot_metric_formats.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. + */ + +enum InfraFormatterType { + number = 'number', + abbreviatedNumber = 'abbreviatedNumber', + bytes = 'bytes', + bits = 'bits', + percent = 'percent', +} + +interface MetricFormatter { + formatter: InfraFormatterType; + template: string; + bounds?: { min: number; max: number }; +} + +interface MetricFormatters { + [key: string]: MetricFormatter; +} + +export const METRIC_FORMATTERS: MetricFormatters = { + ['count']: { formatter: InfraFormatterType.number, template: '{{value}}' }, + ['cpu']: { + formatter: InfraFormatterType.percent, + template: '{{value}}', + }, + ['memory']: { + formatter: InfraFormatterType.percent, + template: '{{value}}', + }, + ['rx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, + ['tx']: { formatter: InfraFormatterType.bits, template: '{{value}}/s' }, + ['logRate']: { + formatter: InfraFormatterType.abbreviatedNumber, + template: '{{value}}/s', + }, + ['diskIOReadBytes']: { + formatter: InfraFormatterType.bytes, + template: '{{value}}/s', + }, + ['diskIOWriteBytes']: { + formatter: InfraFormatterType.bytes, + template: '{{value}}/s', + }, + ['s3BucketSize']: { + formatter: InfraFormatterType.bytes, + template: '{{value}}', + }, + ['s3TotalRequests']: { + formatter: InfraFormatterType.abbreviatedNumber, + template: '{{value}}', + }, + ['s3NumberOfObjects']: { + formatter: InfraFormatterType.abbreviatedNumber, + template: '{{value}}', + }, + ['s3UploadBytes']: { + formatter: InfraFormatterType.bytes, + template: '{{value}}', + }, + ['s3DownloadBytes']: { + formatter: InfraFormatterType.bytes, + template: '{{value}}', + }, + ['sqsOldestMessage']: { + formatter: InfraFormatterType.number, + template: '{{value}} seconds', + }, +}; diff --git a/x-pack/plugins/infra/common/formatters/types.ts b/x-pack/plugins/infra/common/formatters/types.ts new file mode 100644 index 0000000000000..c438ec2d4205d --- /dev/null +++ b/x-pack/plugins/infra/common/formatters/types.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 enum InfraWaffleMapDataFormat { + bytesDecimal = 'bytesDecimal', + bitsDecimal = 'bitsDecimal', + abbreviatedNumber = 'abbreviatedNumber', +} 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 0000000000000..c9f98ac5fcdea --- /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 f23ef7ee7c302..5f02f5598e6a4 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/http_api/metrics_explorer.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts new file mode 100644 index 0000000000000..eb5b0bdbcfbc5 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts @@ -0,0 +1,116 @@ +/* + * 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 METRIC_EXPLORER_AGGREGATIONS = [ + 'avg', + 'max', + 'min', + 'cardinality', + 'rate', + 'count', + 'sum', +] as const; + +type MetricExplorerAggregations = typeof METRIC_EXPLORER_AGGREGATIONS[number]; + +const metricsExplorerAggregationKeys = METRIC_EXPLORER_AGGREGATIONS.reduce< + Record<MetricExplorerAggregations, null> +>((acc, agg) => ({ ...acc, [agg]: null }), {} as Record<MetricExplorerAggregations, null>); + +export const metricsExplorerAggregationRT = rt.keyof(metricsExplorerAggregationKeys); + +export const metricsExplorerMetricRequiredFieldsRT = rt.type({ + aggregation: metricsExplorerAggregationRT, +}); + +export const metricsExplorerMetricOptionalFieldsRT = rt.partial({ + field: rt.union([rt.string, rt.undefined]), +}); + +export const metricsExplorerMetricRT = rt.intersection([ + metricsExplorerMetricRequiredFieldsRT, + metricsExplorerMetricOptionalFieldsRT, +]); + +export const timeRangeRT = rt.type({ + field: rt.string, + from: rt.number, + to: rt.number, + interval: rt.string, +}); + +export const metricsExplorerRequestBodyRequiredFieldsRT = rt.type({ + timerange: timeRangeRT, + indexPattern: rt.string, + metrics: rt.array(metricsExplorerMetricRT), +}); + +export const metricsExplorerRequestBodyOptionalFieldsRT = rt.partial({ + groupBy: rt.union([rt.string, rt.null, rt.undefined]), + afterKey: rt.union([rt.string, rt.null, rt.undefined]), + limit: rt.union([rt.number, rt.null, rt.undefined]), + filterQuery: rt.union([rt.string, rt.null, rt.undefined]), + forceInterval: rt.boolean, +}); + +export const metricsExplorerRequestBodyRT = rt.intersection([ + metricsExplorerRequestBodyRequiredFieldsRT, + metricsExplorerRequestBodyOptionalFieldsRT, +]); + +export const metricsExplorerPageInfoRT = rt.type({ + total: rt.number, + afterKey: rt.union([rt.string, rt.null]), +}); + +export const metricsExplorerColumnTypeRT = rt.keyof({ + date: null, + number: null, + string: null, +}); + +export const metricsExplorerColumnRT = rt.type({ + name: rt.string, + type: metricsExplorerColumnTypeRT, +}); + +export const metricsExplorerRowRT = rt.intersection([ + rt.type({ + timestamp: rt.number, + }), + rt.record(rt.string, rt.union([rt.string, rt.number, rt.null, rt.undefined])), +]); + +export const metricsExplorerSeriesRT = rt.type({ + id: rt.string, + columns: rt.array(metricsExplorerColumnRT), + rows: rt.array(metricsExplorerRowRT), +}); + +export const metricsExplorerResponseRT = rt.type({ + series: rt.array(metricsExplorerSeriesRT), + pageInfo: metricsExplorerPageInfoRT, +}); + +export type MetricsExplorerAggregation = rt.TypeOf<typeof metricsExplorerAggregationRT>; + +export type MetricsExplorerColumnType = rt.TypeOf<typeof metricsExplorerColumnTypeRT>; + +export type MetricsExplorerMetric = rt.TypeOf<typeof metricsExplorerMetricRT>; + +export type MetricsExplorerPageInfo = rt.TypeOf<typeof metricsExplorerPageInfoRT>; + +export type MetricsExplorerColumn = rt.TypeOf<typeof metricsExplorerColumnRT>; + +export type MetricsExplorerRow = rt.TypeOf<typeof metricsExplorerRowRT>; + +export type MetricsExplorerSeries = rt.TypeOf<typeof metricsExplorerSeriesRT>; + +export type MetricsExplorerRequestBody = rt.TypeOf<typeof metricsExplorerRequestBodyRT>; + +export type MetricsExplorerResponse = rt.TypeOf<typeof metricsExplorerResponseRT>; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer/index.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer/index.ts deleted file mode 100644 index 93655f931f45d..0000000000000 --- a/x-pack/plugins/infra/common/http_api/metrics_explorer/index.ts +++ /dev/null @@ -1,114 +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 rt from 'io-ts'; - -export const METRIC_EXPLORER_AGGREGATIONS = [ - 'avg', - 'max', - 'min', - 'cardinality', - 'rate', - 'count', -] as const; - -type MetricExplorerAggregations = typeof METRIC_EXPLORER_AGGREGATIONS[number]; - -const metricsExplorerAggregationKeys = METRIC_EXPLORER_AGGREGATIONS.reduce< - Record<MetricExplorerAggregations, null> ->((acc, agg) => ({ ...acc, [agg]: null }), {} as Record<MetricExplorerAggregations, null>); - -export const metricsExplorerAggregationRT = rt.keyof(metricsExplorerAggregationKeys); - -export const metricsExplorerMetricRequiredFieldsRT = rt.type({ - aggregation: metricsExplorerAggregationRT, -}); - -export const metricsExplorerMetricOptionalFieldsRT = rt.partial({ - field: rt.union([rt.string, rt.undefined]), -}); - -export const metricsExplorerMetricRT = rt.intersection([ - metricsExplorerMetricRequiredFieldsRT, - metricsExplorerMetricOptionalFieldsRT, -]); - -export const timeRangeRT = rt.type({ - field: rt.string, - from: rt.number, - to: rt.number, - interval: rt.string, -}); - -export const metricsExplorerRequestBodyRequiredFieldsRT = rt.type({ - timerange: timeRangeRT, - indexPattern: rt.string, - metrics: rt.array(metricsExplorerMetricRT), -}); - -export const metricsExplorerRequestBodyOptionalFieldsRT = rt.partial({ - groupBy: rt.union([rt.string, rt.null, rt.undefined]), - afterKey: rt.union([rt.string, rt.null, rt.undefined]), - limit: rt.union([rt.number, rt.null, rt.undefined]), - filterQuery: rt.union([rt.string, rt.null, rt.undefined]), -}); - -export const metricsExplorerRequestBodyRT = rt.intersection([ - metricsExplorerRequestBodyRequiredFieldsRT, - metricsExplorerRequestBodyOptionalFieldsRT, -]); - -export const metricsExplorerPageInfoRT = rt.type({ - total: rt.number, - afterKey: rt.union([rt.string, rt.null]), -}); - -export const metricsExplorerColumnTypeRT = rt.keyof({ - date: null, - number: null, - string: null, -}); - -export const metricsExplorerColumnRT = rt.type({ - name: rt.string, - type: metricsExplorerColumnTypeRT, -}); - -export const metricsExplorerRowRT = rt.intersection([ - rt.type({ - timestamp: rt.number, - }), - rt.record(rt.string, rt.union([rt.string, rt.number, rt.null, rt.undefined])), -]); - -export const metricsExplorerSeriesRT = rt.type({ - id: rt.string, - columns: rt.array(metricsExplorerColumnRT), - rows: rt.array(metricsExplorerRowRT), -}); - -export const metricsExplorerResponseRT = rt.type({ - series: rt.array(metricsExplorerSeriesRT), - pageInfo: metricsExplorerPageInfoRT, -}); - -export type MetricsExplorerAggregation = rt.TypeOf<typeof metricsExplorerAggregationRT>; - -export type MetricsExplorerColumnType = rt.TypeOf<typeof metricsExplorerColumnTypeRT>; - -export type MetricsExplorerMetric = rt.TypeOf<typeof metricsExplorerMetricRT>; - -export type MetricsExplorerPageInfo = rt.TypeOf<typeof metricsExplorerPageInfoRT>; - -export type MetricsExplorerColumn = rt.TypeOf<typeof metricsExplorerColumnRT>; - -export type MetricsExplorerRow = rt.TypeOf<typeof metricsExplorerRowRT>; - -export type MetricsExplorerSeries = rt.TypeOf<typeof metricsExplorerSeriesRT>; - -export type MetricsExplorerRequestBody = rt.TypeOf<typeof metricsExplorerRequestBodyRT>; - -export type MetricsExplorerResponse = rt.TypeOf<typeof metricsExplorerResponseRT>; diff --git a/x-pack/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx b/x-pack/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx index b2da7dec3f2e0..764db2164b711 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx +++ b/x-pack/plugins/infra/common/inventory_models/aws_ec2/toolbar_items.tsx @@ -11,27 +11,29 @@ import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_ import { CloudToolbarItems } from '../shared/components/cloud_toolbar_items'; import { SnapshotMetricType } from '../types'; +export const ec2MetricTypes: SnapshotMetricType[] = [ + 'cpu', + 'rx', + 'tx', + 'diskIOReadBytes', + 'diskIOWriteBytes', +]; + +export const ec2groupByFields = [ + 'cloud.availability_zone', + 'cloud.machine.type', + 'aws.ec2.instance.image.id', + 'aws.ec2.instance.state.name', +]; + export const AwsEC2ToolbarItems = (props: ToolbarProps) => { - const metricTypes: SnapshotMetricType[] = [ - 'cpu', - 'rx', - 'tx', - 'diskIOReadBytes', - 'diskIOWriteBytes', - ]; - const groupByFields = [ - 'cloud.availability_zone', - 'cloud.machine.type', - 'aws.ec2.instance.image.id', - 'aws.ec2.instance.state.name', - ]; return ( <> <CloudToolbarItems {...props} /> <MetricsAndGroupByToolbarItems {...props} - metricTypes={metricTypes} - groupByFields={groupByFields} + metricTypes={ec2MetricTypes} + groupByFields={ec2groupByFields} /> </> ); diff --git a/x-pack/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx b/x-pack/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx index 2a8394b9dd3a4..3eebdee22b2c3 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx +++ b/x-pack/plugins/infra/common/inventory_models/aws_rds/toolbar_items.tsx @@ -11,26 +11,28 @@ import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_ import { CloudToolbarItems } from '../shared/components/cloud_toolbar_items'; import { SnapshotMetricType } from '../types'; +export const rdsMetricTypes: SnapshotMetricType[] = [ + 'cpu', + 'rdsConnections', + 'rdsQueriesExecuted', + 'rdsActiveTransactions', + 'rdsLatency', +]; + +export const rdsGroupByFields = [ + 'cloud.availability_zone', + 'aws.rds.db_instance.class', + 'aws.rds.db_instance.status', +]; + export const AwsRDSToolbarItems = (props: ToolbarProps) => { - const metricTypes: SnapshotMetricType[] = [ - 'cpu', - 'rdsConnections', - 'rdsQueriesExecuted', - 'rdsActiveTransactions', - 'rdsLatency', - ]; - const groupByFields = [ - 'cloud.availability_zone', - 'aws.rds.db_instance.class', - 'aws.rds.db_instance.status', - ]; return ( <> <CloudToolbarItems {...props} /> <MetricsAndGroupByToolbarItems {...props} - metricTypes={metricTypes} - groupByFields={groupByFields} + metricTypes={rdsMetricTypes} + groupByFields={rdsGroupByFields} /> </> ); diff --git a/x-pack/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx b/x-pack/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx index 324bdd0586029..ede618b1bf19d 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx +++ b/x-pack/plugins/infra/common/inventory_models/aws_s3/toolbar_items.tsx @@ -11,22 +11,24 @@ import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_ import { CloudToolbarItems } from '../shared/components/cloud_toolbar_items'; import { SnapshotMetricType } from '../types'; +export const s3MetricTypes: SnapshotMetricType[] = [ + 's3BucketSize', + 's3NumberOfObjects', + 's3TotalRequests', + 's3DownloadBytes', + 's3UploadBytes', +]; + +export const s3GroupByFields = ['cloud.region']; + export const AwsS3ToolbarItems = (props: ToolbarProps) => { - const metricTypes: SnapshotMetricType[] = [ - 's3BucketSize', - 's3NumberOfObjects', - 's3TotalRequests', - 's3DownloadBytes', - 's3UploadBytes', - ]; - const groupByFields = ['cloud.region']; return ( <> <CloudToolbarItems {...props} /> <MetricsAndGroupByToolbarItems {...props} - metricTypes={metricTypes} - groupByFields={groupByFields} + metricTypes={s3MetricTypes} + groupByFields={s3GroupByFields} /> </> ); diff --git a/x-pack/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx b/x-pack/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx index 3229c07034772..e77f3af578197 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx +++ b/x-pack/plugins/infra/common/inventory_models/aws_sqs/toolbar_items.tsx @@ -11,22 +11,23 @@ import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_ import { CloudToolbarItems } from '../shared/components/cloud_toolbar_items'; import { SnapshotMetricType } from '../types'; +export const sqsMetricTypes: SnapshotMetricType[] = [ + 'sqsMessagesVisible', + 'sqsMessagesDelayed', + 'sqsMessagesSent', + 'sqsMessagesEmpty', + 'sqsOldestMessage', +]; +export const sqsGroupByFields = ['cloud.region']; + export const AwsSQSToolbarItems = (props: ToolbarProps) => { - const metricTypes: SnapshotMetricType[] = [ - 'sqsMessagesVisible', - 'sqsMessagesDelayed', - 'sqsMessagesSent', - 'sqsMessagesEmpty', - 'sqsOldestMessage', - ]; - const groupByFields = ['cloud.region']; return ( <> <CloudToolbarItems {...props} /> <MetricsAndGroupByToolbarItems {...props} - metricTypes={metricTypes} - groupByFields={groupByFields} + metricTypes={sqsMetricTypes} + groupByFields={sqsGroupByFields} /> </> ); diff --git a/x-pack/plugins/infra/common/inventory_models/container/toolbar_items.tsx b/x-pack/plugins/infra/common/inventory_models/container/toolbar_items.tsx index f6c707726d9ca..f193adbf6aadc 100644 --- a/x-pack/plugins/infra/common/inventory_models/container/toolbar_items.tsx +++ b/x-pack/plugins/infra/common/inventory_models/container/toolbar_items.tsx @@ -10,21 +10,22 @@ import { ToolbarProps } from '../../../public/pages/metrics/inventory_view/compo import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_groupby_toolbar_items'; import { SnapshotMetricType } from '../types'; +export const containerMetricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx']; +export const containerGroupByFields = [ + 'host.name', + 'cloud.availability_zone', + 'cloud.machine.type', + 'cloud.project.id', + 'cloud.provider', + 'service.type', +]; + export const ContainerToolbarItems = (props: ToolbarProps) => { - const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx']; - const groupByFields = [ - 'host.name', - 'cloud.availability_zone', - 'cloud.machine.type', - 'cloud.project.id', - 'cloud.provider', - 'service.type', - ]; return ( <MetricsAndGroupByToolbarItems {...props} - metricTypes={metricTypes} - groupByFields={groupByFields} + metricTypes={containerMetricTypes} + groupByFields={containerGroupByFields} /> ); }; diff --git a/x-pack/plugins/infra/common/inventory_models/host/toolbar_items.tsx b/x-pack/plugins/infra/common/inventory_models/host/toolbar_items.tsx index 136264c0e26f4..8ed684b3885de 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/toolbar_items.tsx +++ b/x-pack/plugins/infra/common/inventory_models/host/toolbar_items.tsx @@ -10,20 +10,27 @@ import { ToolbarProps } from '../../../public/pages/metrics/inventory_view/compo import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_groupby_toolbar_items'; import { SnapshotMetricType } from '../types'; +export const hostMetricTypes: SnapshotMetricType[] = [ + 'cpu', + 'memory', + 'load', + 'rx', + 'tx', + 'logRate', +]; +export const hostGroupByFields = [ + 'cloud.availability_zone', + 'cloud.machine.type', + 'cloud.project.id', + 'cloud.provider', + 'service.type', +]; export const HostToolbarItems = (props: ToolbarProps) => { - const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'load', 'rx', 'tx', 'logRate']; - const groupByFields = [ - 'cloud.availability_zone', - 'cloud.machine.type', - 'cloud.project.id', - 'cloud.provider', - 'service.type', - ]; return ( <MetricsAndGroupByToolbarItems {...props} - metricTypes={metricTypes} - groupByFields={groupByFields} + metricTypes={hostMetricTypes} + groupByFields={hostGroupByFields} /> ); }; diff --git a/x-pack/plugins/infra/common/inventory_models/pod/toolbar_items.tsx b/x-pack/plugins/infra/common/inventory_models/pod/toolbar_items.tsx index c1cd375ff47bf..54a32e3e0180a 100644 --- a/x-pack/plugins/infra/common/inventory_models/pod/toolbar_items.tsx +++ b/x-pack/plugins/infra/common/inventory_models/pod/toolbar_items.tsx @@ -10,14 +10,15 @@ import { ToolbarProps } from '../../../public/pages/metrics/inventory_view/compo import { MetricsAndGroupByToolbarItems } from '../shared/components/metrics_and_groupby_toolbar_items'; import { SnapshotMetricType } from '../types'; +export const podMetricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx']; +export const podGroupByFields = ['kubernetes.namespace', 'kubernetes.node.name', 'service.type']; + export const PodToolbarItems = (props: ToolbarProps) => { - const metricTypes: SnapshotMetricType[] = ['cpu', 'memory', 'rx', 'tx']; - const groupByFields = ['kubernetes.namespace', 'kubernetes.node.name', 'service.type']; return ( <MetricsAndGroupByToolbarItems {...props} - metricTypes={metricTypes} - groupByFields={groupByFields} + metricTypes={podMetricTypes} + groupByFields={podGroupByFields} /> ); }; 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 94643e21f1ea6..7e10e45bbae4d 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<typeof datasetFilterRT>; + +export const jobSourceConfigurationRT = rt.partial({ indexPattern: rt.string, timestampField: rt.string, bucketSpan: rt.number, + datasetFilter: datasetFilterRT, }); export type JobSourceConfiguration = rt.TypeOf<typeof jobSourceConfigurationRT>; 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<typeof jobCustomSettingsRT>; + +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/common/saved_objects/inventory_view.ts b/x-pack/plugins/infra/common/saved_objects/inventory_view.ts index 8933de57b0448..c14b75efc6887 100644 --- a/x-pack/plugins/infra/common/saved_objects/inventory_view.ts +++ b/x-pack/plugins/infra/common/saved_objects/inventory_view.ts @@ -5,18 +5,18 @@ */ // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ElasticsearchMappingOf } from '../../server/utils/typed_elasticsearch_mappings'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { WaffleViewState } from '../../public/pages/metrics/inventory_view/hooks/use_waffle_view_state'; +import { SavedObjectsType } from 'src/core/server'; -export const inventoryViewSavedObjectType = 'inventory-view'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedViewSavedObject } from '../../public/hooks/use_saved_view'; +export const inventoryViewSavedObjectName = 'inventory-view'; -export const inventoryViewSavedObjectMappings: { - [inventoryViewSavedObjectType]: ElasticsearchMappingOf<SavedViewSavedObject<WaffleViewState>>; -} = { - [inventoryViewSavedObjectType]: { +export const inventoryViewSavedObjectType: SavedObjectsType = { + name: inventoryViewSavedObjectName, + hidden: false, + namespaceType: 'single', + management: { + importableAndExportable: true, + }, + mappings: { properties: { name: { type: 'keyword', diff --git a/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts b/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts index 6eb08eabc15b5..88bbc945e32dc 100644 --- a/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts +++ b/x-pack/plugins/infra/common/saved_objects/metrics_explorer_view.ts @@ -5,36 +5,27 @@ */ // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ElasticsearchMappingOf } from '../../server/utils/typed_elasticsearch_mappings'; -import { - MetricsExplorerOptions, - MetricsExplorerChartOptions, - MetricsExplorerTimeOptions, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedViewSavedObject } from '../../public/hooks/use_saved_view'; - -interface MetricsExplorerSavedView { - options: MetricsExplorerOptions; - chartOptions: MetricsExplorerChartOptions; - currentTimerange: MetricsExplorerTimeOptions; -} +import { SavedObjectsType } from 'src/core/server'; -export const metricsExplorerViewSavedObjectType = 'metrics-explorer-view'; +export const metricsExplorerViewSavedObjectName = 'metrics-explorer-view'; -export const metricsExplorerViewSavedObjectMappings: { - [metricsExplorerViewSavedObjectType]: ElasticsearchMappingOf< - SavedViewSavedObject<MetricsExplorerSavedView> - >; -} = { - [metricsExplorerViewSavedObjectType]: { +export const metricsExplorerViewSavedObjectType: SavedObjectsType = { + name: metricsExplorerViewSavedObjectName, + hidden: false, + namespaceType: 'single', + management: { + importableAndExportable: true, + }, + mappings: { properties: { name: { type: 'keyword', }, options: { properties: { + forceInterval: { + type: 'boolean', + }, metrics: { type: 'nested', properties: { diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index a15465a0cde66..ea66ae7a46d4e 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -4,7 +4,6 @@ "kibanaVersion": "kibana", "requiredPlugins": [ "features", - "apm", "usageCollection", "spaces", "home", diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx new file mode 100644 index 0000000000000..8bcf0e9ed5be5 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx @@ -0,0 +1,62 @@ +/* + * 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, useCallback, useMemo } from 'react'; +import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AlertFlyout } from './alert_flyout'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; + +export const MetricsAlertDropdown = () => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [flyoutVisible, setFlyoutVisible] = useState(false); + const kibana = useKibana(); + + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, [setPopoverOpen]); + + const openPopover = useCallback(() => { + setPopoverOpen(true); + }, [setPopoverOpen]); + + const menuItems = useMemo(() => { + return [ + <EuiContextMenuItem icon="bell" key="createLink" onClick={() => setFlyoutVisible(true)}> + <FormattedMessage + id="xpack.infra.alerting.createAlertButton" + defaultMessage="Create alert" + /> + </EuiContextMenuItem>, + <EuiContextMenuItem + icon="tableOfContents" + key="manageLink" + href={kibana.services?.application?.getUrlForApp( + 'kibana#/management/kibana/triggersActions/alerts' + )} + > + <FormattedMessage id="xpack.infra.alerting.manageAlerts" defaultMessage="Manage alerts" /> + </EuiContextMenuItem>, + ]; + }, [kibana.services]); + + return ( + <> + <EuiPopover + button={ + <EuiButtonEmpty iconSide={'right'} iconType={'arrowDown'} onClick={openPopover}> + <FormattedMessage id="xpack.infra.alerting.alertsButton" defaultMessage="Alerts" /> + </EuiButtonEmpty> + } + isOpen={popoverOpen} + closePopover={closePopover} + > + <EuiContextMenuPanel items={menuItems} /> + </EuiPopover> + <AlertFlyout setVisible={setFlyoutVisible} visible={flyoutVisible} /> + </> + ); +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx new file mode 100644 index 0000000000000..b0c8cdb9d4195 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx @@ -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 React, { useContext } from 'react'; +import { AlertsContextProvider, AlertAdd } from '../../../../../triggers_actions_ui/public'; +import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types'; +import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer'; +import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; + +interface Props { + visible?: boolean; + options?: Partial<MetricsExplorerOptions>; + series?: MetricsExplorerSeries; + setVisible: React.Dispatch<React.SetStateAction<boolean>>; +} + +export const AlertFlyout = (props: Props) => { + const { triggersActionsUI } = useContext(TriggerActionsContext); + const { services } = useKibana(); + + return ( + <> + {triggersActionsUI && ( + <AlertsContextProvider + value={{ + metadata: { + currentOptions: props.options, + series: props.series, + }, + toastNotifications: services.notifications?.toasts, + http: services.http, + docLinks: services.docLinks, + capabilities: services.application.capabilities, + actionTypeRegistry: triggersActionsUI.actionTypeRegistry, + alertTypeRegistry: triggersActionsUI.alertTypeRegistry, + }} + > + <AlertAdd + addFlyoutVisible={props.visible!} + setAddFlyoutVisibility={props.setVisible} + alertTypeId={METRIC_THRESHOLD_ALERT_TYPE_ID} + canChangeTrigger={false} + consumer={'metrics'} + /> + </AlertsContextProvider> + )} + </> + ); +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx new file mode 100644 index 0000000000000..5e14babddcb07 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -0,0 +1,359 @@ +/* + * 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, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react'; +import { + EuiSpacer, + EuiText, + EuiFormRow, + EuiButtonEmpty, + EuiCheckbox, + EuiToolTip, + EuiIcon, + EuiFieldSearch, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + Comparator, + Aggregators, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../server/lib/alerting/metric_threshold/types'; +import { + ForLastExpression, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/common'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context'; +import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; +import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; +import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by'; +import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; +import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; + +import { ExpressionRow } from './expression_row'; +import { AlertContextMeta, TimeUnit, MetricExpression } from '../types'; +import { ExpressionChart } from './expression_chart'; + +interface Props { + errors: IErrorObject[]; + alertParams: { + criteria: MetricExpression[]; + groupBy?: string; + filterQuery?: string; + sourceId?: string; + filterQueryText?: string; + alertOnNoData?: boolean; + }; + alertsContext: AlertsContextValue<AlertContextMeta>; + setAlertParams(key: string, value: any): void; + setAlertProperty(key: string, value: any): void; +} + +const defaultExpression = { + aggType: Aggregators.AVERAGE, + comparator: Comparator.GT, + threshold: [], + timeSize: 1, + timeUnit: 'm', +} as MetricExpression; + +export const Expressions: React.FC<Props> = props => { + const { setAlertParams, alertParams, errors, alertsContext } = props; + const { source, createDerivedIndexPattern } = useSourceViaHttp({ + sourceId: 'default', + type: 'metrics', + fetch: alertsContext.http.fetch, + toastWarning: alertsContext.toastNotifications.addWarning, + }); + const [timeSize, setTimeSize] = useState<number | undefined>(1); + const [timeUnit, setTimeUnit] = useState<TimeUnit>('m'); + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + createDerivedIndexPattern, + ]); + + const options = useMemo<MetricsExplorerOptions>(() => { + if (alertsContext.metadata?.currentOptions?.metrics) { + return alertsContext.metadata.currentOptions as MetricsExplorerOptions; + } else { + return { + metrics: [], + aggregation: 'avg', + }; + } + }, [alertsContext.metadata]); + + const updateParams = useCallback( + (id, e: MetricExpression) => { + const exp = alertParams.criteria ? alertParams.criteria.slice() : []; + exp[id] = { ...exp[id], ...e }; + setAlertParams('criteria', exp); + }, + [setAlertParams, alertParams.criteria] + ); + + const addExpression = useCallback(() => { + const exp = alertParams.criteria?.slice() || []; + exp.push(defaultExpression); + setAlertParams('criteria', exp); + }, [setAlertParams, alertParams.criteria]); + + const removeExpression = useCallback( + (id: number) => { + const exp = alertParams.criteria?.slice() || []; + if (exp.length > 1) { + exp.splice(id, 1); + setAlertParams('criteria', exp); + } + }, + [setAlertParams, alertParams.criteria] + ); + + const onFilterChange = useCallback( + (filter: any) => { + setAlertParams('filterQueryText', filter); + setAlertParams( + 'filterQuery', + convertKueryToElasticSearchQuery(filter, derivedIndexPattern) || '' + ); + }, + [setAlertParams, derivedIndexPattern] + ); + + const onGroupByChange = useCallback( + (group: string | null) => { + setAlertParams('groupBy', group || ''); + }, + [setAlertParams] + ); + + const emptyError = useMemo(() => { + return { + aggField: [], + timeSizeUnit: [], + timeWindowSize: [], + }; + }, []); + + const updateTimeSize = useCallback( + (ts: number | undefined) => { + const criteria = + alertParams.criteria?.map(c => ({ + ...c, + timeSize: ts, + })) || []; + setTimeSize(ts || undefined); + setAlertParams('criteria', criteria); + }, + [alertParams.criteria, setAlertParams] + ); + + const updateTimeUnit = useCallback( + (tu: string) => { + const criteria = + alertParams.criteria?.map(c => ({ + ...c, + timeUnit: tu, + })) || []; + setTimeUnit(tu as TimeUnit); + setAlertParams('criteria', criteria); + }, + [alertParams.criteria, setAlertParams] + ); + + useEffect(() => { + const md = alertsContext.metadata; + if (md) { + if (md.currentOptions?.metrics) { + setAlertParams( + 'criteria', + md.currentOptions.metrics.map(metric => ({ + metric: metric.field, + comparator: Comparator.GT, + threshold: [], + timeSize, + timeUnit, + aggType: metric.aggregation, + })) + ); + } else { + setAlertParams('criteria', [defaultExpression]); + } + + if (md.currentOptions) { + if (md.currentOptions.filterQuery) { + setAlertParams('filterQueryText', md.currentOptions.filterQuery); + setAlertParams( + 'filterQuery', + convertKueryToElasticSearchQuery(md.currentOptions.filterQuery, derivedIndexPattern) || + '' + ); + } else if (md.currentOptions.groupBy && md.series) { + const filter = `${md.currentOptions.groupBy}: "${md.series.id}"`; + setAlertParams('filterQueryText', filter); + setAlertParams( + 'filterQuery', + convertKueryToElasticSearchQuery(filter, derivedIndexPattern) || '' + ); + } + + setAlertParams('groupBy', md.currentOptions.groupBy); + } + setAlertParams('sourceId', source?.id); + } else { + if (!alertParams.criteria) { + setAlertParams('criteria', [defaultExpression]); + } + if (!alertParams.sourceId) { + setAlertParams('sourceId', source?.id || 'default'); + } + } + }, [alertsContext.metadata, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleFieldSearchChange = useCallback( + (e: ChangeEvent<HTMLInputElement>) => onFilterChange(e.target.value), + [onFilterChange] + ); + + return ( + <> + <EuiSpacer size={'m'} /> + <EuiText size="xs"> + <h4> + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.conditions" + defaultMessage="Conditions" + /> + </h4> + </EuiText> + <EuiSpacer size={'xs'} /> + {alertParams.criteria && + alertParams.criteria.map((e, idx) => { + return ( + <ExpressionRow + canDelete={(alertParams.criteria && alertParams.criteria.length > 1) || false} + fields={derivedIndexPattern.fields} + remove={removeExpression} + addExpression={addExpression} + key={idx} // idx's don't usually make good key's but here the index has semantic meaning + expressionId={idx} + setAlertParams={updateParams} + errors={errors[idx] || emptyError} + expression={e || {}} + > + <ExpressionChart + expression={e} + context={alertsContext} + derivedIndexPattern={derivedIndexPattern} + source={source} + filterQuery={alertParams.filterQuery} + groupBy={alertParams.groupBy} + /> + </ExpressionRow> + ); + })} + + <div style={{ marginLeft: 28 }}> + <ForLastExpression + timeWindowSize={timeSize} + timeWindowUnit={timeUnit} + errors={emptyError} + onChangeWindowSize={updateTimeSize} + onChangeWindowUnit={updateTimeUnit} + /> + </div> + + <div> + <EuiButtonEmpty + color={'primary'} + iconSide={'left'} + flush={'left'} + iconType={'plusInCircleFilled'} + onClick={addExpression} + > + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.addCondition" + defaultMessage="Add condition" + /> + </EuiButtonEmpty> + </div> + + <EuiSpacer size={'m'} /> + <EuiCheckbox + id="metrics-alert-no-data-toggle" + label={ + <> + {i18n.translate('xpack.infra.metrics.alertFlyout.alertOnNoData', { + defaultMessage: "Alert me if there's no data", + })}{' '} + <EuiToolTip + content={i18n.translate('xpack.infra.metrics.alertFlyout.noDataHelpText', { + defaultMessage: + 'Enable this to trigger the action if the metric(s) do not report any data over the expected time period, or if the alert fails to query Elasticsearch', + })} + > + <EuiIcon type="questionInCircle" color="subdued" /> + </EuiToolTip> + </> + } + checked={alertParams.alertOnNoData} + onChange={e => setAlertParams('alertOnNoData', e.target.checked)} + /> + + <EuiSpacer size={'m'} /> + + <EuiFormRow + label={i18n.translate('xpack.infra.metrics.alertFlyout.filterLabel', { + defaultMessage: 'Filter (optional)', + })} + helpText={i18n.translate('xpack.infra.metrics.alertFlyout.filterHelpText', { + defaultMessage: 'Use a KQL expression to limit the scope of your alert trigger.', + })} + fullWidth + compressed + > + {(alertsContext.metadata && ( + <MetricsExplorerKueryBar + derivedIndexPattern={derivedIndexPattern} + onChange={onFilterChange} + onSubmit={onFilterChange} + value={alertParams.filterQueryText} + /> + )) || ( + <EuiFieldSearch + onChange={handleFieldSearchChange} + value={alertParams.filterQueryText} + fullWidth + /> + )} + </EuiFormRow> + + <EuiSpacer size={'m'} /> + <EuiFormRow + label={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerText', { + defaultMessage: 'Create alert per (optional)', + })} + helpText={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerHelpText', { + defaultMessage: + 'Create an alert for every unique value. For example: "host.id" or "cloud.region".', + })} + fullWidth + compressed + > + <MetricsExplorerGroupBy + onChange={onGroupByChange} + fields={derivedIndexPattern.fields} + options={{ + ...options, + groupBy: alertParams.groupBy || undefined, + }} + /> + </EuiFormRow> + </> + ); +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx new file mode 100644 index 0000000000000..a600d59865ccc --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx @@ -0,0 +1,302 @@ +/* + * 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, { useMemo, useCallback } from 'react'; +import { + Axis, + Chart, + niceTimeFormatter, + Position, + Settings, + TooltipValue, + RectAnnotation, +} from '@elastic/charts'; +import { first, last } from 'lodash'; +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { IIndexPattern } from 'src/plugins/data/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context'; +import { InfraSource } from '../../../../common/http_api/source_api'; +import { + Comparator, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../server/lib/alerting/metric_threshold/types'; +import { MetricsExplorerColor, colorTransformer } from '../../../../common/color_palette'; +import { MetricsExplorerRow, MetricsExplorerAggregation } from '../../../../common/http_api'; +import { MetricExplorerSeriesChart } from '../../../pages/metrics/metrics_explorer/components/series_chart'; +import { MetricExpression, AlertContextMeta } from '../types'; +import { MetricsExplorerChartType } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; +import { getChartTheme } from '../../../pages/metrics/metrics_explorer/components/helpers/get_chart_theme'; +import { createFormatterForMetric } from '../../../pages/metrics/metrics_explorer/components/helpers/create_formatter_for_metric'; +import { calculateDomain } from '../../../pages/metrics/metrics_explorer/components/helpers/calculate_domain'; +import { useMetricsExplorerChartData } from '../hooks/use_metrics_explorer_chart_data'; + +interface Props { + context: AlertsContextValue<AlertContextMeta>; + expression: MetricExpression; + derivedIndexPattern: IIndexPattern; + source: InfraSource | null; + filterQuery?: string; + groupBy?: string; +} + +const tooltipProps = { + headerFormatter: (tooltipValue: TooltipValue) => + moment(tooltipValue.value).format('Y-MM-DD HH:mm:ss.SSS'), +}; + +const TIME_LABELS = { + s: i18n.translate('xpack.infra.metrics.alerts.timeLabels.seconds', { defaultMessage: 'seconds' }), + m: i18n.translate('xpack.infra.metrics.alerts.timeLabels.minutes', { defaultMessage: 'minutes' }), + h: i18n.translate('xpack.infra.metrics.alerts.timeLabels.hours', { defaultMessage: 'hours' }), + d: i18n.translate('xpack.infra.metrics.alerts.timeLabels.days', { defaultMessage: 'days' }), +}; + +export const ExpressionChart: React.FC<Props> = ({ + expression, + context, + derivedIndexPattern, + source, + filterQuery, + groupBy, +}) => { + const { loading, data } = useMetricsExplorerChartData( + expression, + context, + derivedIndexPattern, + source, + filterQuery, + groupBy + ); + + const metric = { + field: expression.metric, + aggregation: expression.aggType as MetricsExplorerAggregation, + color: MetricsExplorerColor.color0, + }; + const isDarkMode = context.uiSettings?.get('theme:darkMode') || false; + const dateFormatter = useMemo(() => { + const firstSeries = data ? first(data.series) : null; + return firstSeries && firstSeries.rows.length > 0 + ? niceTimeFormatter([first(firstSeries.rows).timestamp, last(firstSeries.rows).timestamp]) + : (value: number) => `${value}`; + }, [data]); + + const yAxisFormater = useCallback(createFormatterForMetric(metric), [expression]); + + if (loading || !data) { + return ( + <EmptyContainer> + <EuiText color="subdued"> + <FormattedMessage + id="xpack.infra.metrics.alerts.loadingMessage" + defaultMessage="Loading" + /> + </EuiText> + </EmptyContainer> + ); + } + + const thresholds = expression.threshold.slice().sort(); + + // Creating a custom series where the ID is changed to 0 + // so that we can get a proper domian + const firstSeries = first(data.series); + if (!firstSeries) { + return ( + <EmptyContainer> + <EuiText color="subdued">Oops, no chart data available</EuiText> + </EmptyContainer> + ); + } + + const series = { + ...firstSeries, + rows: firstSeries.rows.map(row => { + const newRow: MetricsExplorerRow = { + timestamp: row.timestamp, + metric_0: row.metric_0 || null, + }; + thresholds.forEach((thresholdValue, index) => { + newRow[`metric_threshold_${index}`] = thresholdValue; + }); + return newRow; + }), + }; + + const firstTimestamp = first(firstSeries.rows).timestamp; + const lastTimestamp = last(firstSeries.rows).timestamp; + const dataDomain = calculateDomain(series, [metric], false); + const domain = { + max: Math.max(dataDomain.max, last(thresholds) || dataDomain.max) * 1.1, // add 10% headroom. + min: Math.min(dataDomain.min, first(thresholds) || dataDomain.min), + }; + + if (domain.min === first(expression.threshold)) { + domain.min = domain.min * 0.9; + } + + const isAbove = [Comparator.GT, Comparator.GT_OR_EQ].includes(expression.comparator); + const opacity = 0.3; + const timeLabel = TIME_LABELS[expression.timeUnit]; + + return ( + <> + <ChartContainer> + <Chart> + <MetricExplorerSeriesChart + type={MetricsExplorerChartType.area} + metric={metric} + id="0" + series={series} + stack={false} + /> + {thresholds.length ? ( + <MetricExplorerSeriesChart + type={isAbove ? MetricsExplorerChartType.line : MetricsExplorerChartType.area} + metric={{ + ...metric, + color: MetricsExplorerColor.color1, + label: i18n.translate('xpack.infra.metrics.alerts.thresholdLabel', { + defaultMessage: 'Threshold', + }), + }} + id={thresholds.map((t, i) => `threshold_${i}`)} + series={series} + stack={false} + opacity={opacity} + /> + ) : null} + {thresholds.length && expression.comparator === Comparator.OUTSIDE_RANGE ? ( + <> + <MetricExplorerSeriesChart + type={MetricsExplorerChartType.line} + metric={{ + ...metric, + color: MetricsExplorerColor.color1, + label: i18n.translate('xpack.infra.metrics.alerts.thresholdLabel', { + defaultMessage: 'Threshold', + }), + }} + id={thresholds.map((t, i) => `threshold_${i}`)} + series={series} + stack={false} + opacity={opacity} + /> + <RectAnnotation + id="lower-threshold" + style={{ + fill: colorTransformer(MetricsExplorerColor.color1), + opacity, + }} + dataValues={[ + { + coordinates: { + x0: firstTimestamp, + x1: lastTimestamp, + y0: domain.min, + y1: first(expression.threshold), + }, + }, + ]} + /> + <RectAnnotation + id="upper-threshold" + style={{ + fill: colorTransformer(MetricsExplorerColor.color1), + opacity, + }} + dataValues={[ + { + coordinates: { + x0: firstTimestamp, + x1: lastTimestamp, + y0: last(expression.threshold), + y1: domain.max, + }, + }, + ]} + /> + </> + ) : null} + {isAbove ? ( + <RectAnnotation + id="upper-threshold" + style={{ + fill: colorTransformer(MetricsExplorerColor.color1), + opacity, + }} + dataValues={[ + { + coordinates: { + x0: firstTimestamp, + x1: lastTimestamp, + y0: first(expression.threshold), + y1: domain.max, + }, + }, + ]} + /> + ) : null} + <Axis + id={'timestamp'} + position={Position.Bottom} + showOverlappingTicks={true} + tickFormat={dateFormatter} + /> + <Axis id={'values'} position={Position.Left} tickFormat={yAxisFormater} domain={domain} /> + <Settings tooltip={tooltipProps} theme={getChartTheme(isDarkMode)} /> + </Chart> + </ChartContainer> + <div style={{ textAlign: 'center' }}> + {series.id !== 'ALL' ? ( + <EuiText size="xs" color="subdued"> + <FormattedMessage + id="xpack.infra.metrics.alerts.dataTimeRangeLabelWithGrouping" + defaultMessage="Last 20 {timeLabel} of data for {id}" + values={{ id: series.id, timeLabel }} + /> + </EuiText> + ) : ( + <EuiText size="xs" color="subdued"> + <FormattedMessage + id="xpack.infra.metrics.alerts.dataTimeRangeLabel" + defaultMessage="Last 20 {timeLabel}" + values={{ timeLabel }} + /> + </EuiText> + )} + </div> + </> + ); +}; + +const EmptyContainer: React.FC = ({ children }) => ( + <div + style={{ + width: '100%', + height: 150, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }} + > + {children} + </div> +); + +const ChartContainer: React.FC = ({ children }) => ( + <div + style={{ + width: '100%', + height: 150, + }} + > + {children} + </div> +); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx new file mode 100644 index 0000000000000..8801df380b48d --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_row.tsx @@ -0,0 +1,237 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiSpacer } from '@elastic/eui'; +import { IFieldType } from 'src/plugins/data/public'; +import { + WhenExpression, + OfExpression, + ThresholdExpression, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/common'; +import { euiStyled } from '../../../../../observability/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { MetricExpression, AGGREGATION_TYPES } from '../types'; +import { + Comparator, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../server/lib/alerting/metric_threshold/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { builtInComparators } from '../../../../../triggers_actions_ui/public/common/constants'; + +const customComparators = { + ...builtInComparators, + [Comparator.OUTSIDE_RANGE]: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.outsideRangeLabel', { + defaultMessage: 'Is not between', + }), + value: Comparator.OUTSIDE_RANGE, + requiredValues: 2, + }, +}; + +interface ExpressionRowProps { + fields: IFieldType[]; + expressionId: number; + expression: MetricExpression; + errors: IErrorObject; + canDelete: boolean; + addExpression(): void; + remove(id: number): void; + setAlertParams(id: number, params: MetricExpression): void; +} + +const StyledExpressionRow = euiStyled(EuiFlexGroup)` + display: flex; + flex-wrap: wrap; + margin: 0 -4px; +`; + +const StyledExpression = euiStyled.div` + padding: 0 4px; +`; + +export const ExpressionRow: React.FC<ExpressionRowProps> = props => { + const [isExpanded, setRowState] = useState(true); + const toggleRowState = useCallback(() => setRowState(!isExpanded), [isExpanded]); + const { + children, + setAlertParams, + expression, + errors, + expressionId, + remove, + fields, + canDelete, + } = props; + const { + aggType = AGGREGATION_TYPES.MAX, + metric, + comparator = Comparator.GT, + threshold = [], + } = expression; + + const updateAggType = useCallback( + (at: string) => { + setAlertParams(expressionId, { + ...expression, + aggType: at as MetricExpression['aggType'], + metric: at === 'count' ? undefined : expression.metric, + }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateMetric = useCallback( + (m?: MetricExpression['metric']) => { + setAlertParams(expressionId, { ...expression, metric: m }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateComparator = useCallback( + (c?: string) => { + setAlertParams(expressionId, { ...expression, comparator: c as Comparator }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateThreshold = useCallback( + t => { + if (t.join() !== expression.threshold.join()) { + setAlertParams(expressionId, { ...expression, threshold: t }); + } + }, + [expressionId, expression, setAlertParams] + ); + + return ( + <> + <EuiFlexGroup gutterSize="xs"> + <EuiFlexItem grow={false}> + <EuiButtonIcon + iconType={isExpanded ? 'arrowDown' : 'arrowRight'} + onClick={toggleRowState} + aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.expandRowLabel', { + defaultMessage: 'Expand row.', + })} + /> + </EuiFlexItem> + <EuiFlexItem grow> + <StyledExpressionRow> + <StyledExpression> + <WhenExpression + customAggTypesOptions={aggregationType} + aggType={aggType} + onChangeSelectedAggType={updateAggType} + /> + </StyledExpression> + {aggType !== 'count' && ( + <StyledExpression> + <OfExpression + customAggTypesOptions={aggregationType} + aggField={metric} + fields={fields.map(f => ({ + normalizedType: f.type, + name: f.name, + }))} + aggType={aggType} + errors={errors} + onChangeSelectedAggField={updateMetric} + /> + </StyledExpression> + )} + <StyledExpression> + <ThresholdExpression + thresholdComparator={comparator || Comparator.GT} + threshold={threshold} + customComparators={customComparators} + onChangeSelectedThresholdComparator={updateComparator} + onChangeSelectedThreshold={updateThreshold} + errors={errors} + /> + </StyledExpression> + </StyledExpressionRow> + </EuiFlexItem> + {canDelete && ( + <EuiFlexItem grow={false}> + <EuiButtonIcon + aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.removeCondition', { + defaultMessage: 'Remove condition', + })} + color={'danger'} + iconType={'trash'} + onClick={() => remove(expressionId)} + /> + </EuiFlexItem> + )} + </EuiFlexGroup> + {isExpanded ? <div style={{ padding: '0 0 0 28px' }}>{children}</div> : null} + <EuiSpacer size={'s'} /> + </> + ); +}; + +export const aggregationType: { [key: string]: any } = { + avg: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.avg', { + defaultMessage: 'Average', + }), + fieldRequired: true, + validNormalizedTypes: ['number'], + value: AGGREGATION_TYPES.AVERAGE, + }, + max: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.max', { + defaultMessage: 'Max', + }), + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MAX, + }, + min: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.min', { + defaultMessage: 'Min', + }), + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MIN, + }, + cardinality: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.cardinality', { + defaultMessage: 'Cardinality', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.CARDINALITY, + validNormalizedTypes: ['number'], + }, + rate: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.rate', { + defaultMessage: 'Rate', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.RATE, + validNormalizedTypes: ['number'], + }, + count: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.count', { + defaultMessage: 'Document count', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.COUNT, + validNormalizedTypes: ['number'], + }, + sum: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.sum', { + defaultMessage: 'Sum', + }), + fieldRequired: false, + value: AGGREGATION_TYPES.SUM, + validNormalizedTypes: ['number'], + }, +}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx similarity index 100% rename from x-pack/plugins/infra/public/components/alerting/metrics/validation.tsx rename to x-pack/plugins/infra/public/alerting/metric_threshold/components/validation.tsx diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts new file mode 100644 index 0000000000000..67f66bf742f43 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/hooks/use_metrics_explorer_chart_data.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IIndexPattern } from 'src/plugins/data/public'; +import { useMemo } from 'react'; +import { InfraSource } from '../../../../common/http_api/source_api'; +import { AlertContextMeta, MetricExpression } from '../types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context'; +import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; +import { useMetricsExplorerData } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data'; + +export const useMetricsExplorerChartData = ( + expression: MetricExpression, + context: AlertsContextValue<AlertContextMeta>, + derivedIndexPattern: IIndexPattern, + source: InfraSource | null, + filterQuery?: string, + groupBy?: string +) => { + const { timeSize, timeUnit } = expression || { timeSize: 1, timeUnit: 'm' }; + const options: MetricsExplorerOptions = useMemo( + () => ({ + limit: 1, + forceInterval: true, + groupBy, + filterQuery, + metrics: [ + { + field: expression.metric, + aggregation: expression.aggType, + }, + ], + aggregation: expression.aggType || 'avg', + }), + [expression.aggType, expression.metric, filterQuery, groupBy] + ); + const timerange = useMemo( + () => ({ + interval: `>=${timeSize || 1}${timeUnit}`, + from: `now-${(timeSize || 1) * 20}${timeUnit}`, + to: 'now', + }), + [timeSize, timeUnit] + ); + + return useMetricsExplorerData( + options, + source?.configuration, + derivedIndexPattern, + timerange, + null, + null, + context.http.fetch + ); +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/index.ts new file mode 100644 index 0000000000000..91b9bafad5011 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/index.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 { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../triggers_actions_ui/public/types'; +import { Expressions } from './components/expression'; +import { validateMetricThreshold } from './components/validation'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../server/lib/alerting/metric_threshold/types'; + +export function createMetricThresholdAlertType(): AlertTypeModel { + return { + id: METRIC_THRESHOLD_ALERT_TYPE_ID, + name: i18n.translate('xpack.infra.metrics.alertFlyout.alertName', { + defaultMessage: 'Metric threshold', + }), + iconClass: 'bell', + alertParamsExpression: Expressions, + validate: validateMetricThreshold, + defaultActionMessage: i18n.translate( + 'xpack.infra.metrics.alerting.threshold.defaultActionMessage', + { + defaultMessage: `\\{\\{alertName\\}\\} - \\{\\{context.group\\}\\} is in a state of \\{\\{context.alertState\\}\\} + +Reason: +\\{\\{context.reason\\}\\} +`, + } + ), + }; +} diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/lib/transform_metrics_explorer_data.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/lib/transform_metrics_explorer_data.ts new file mode 100644 index 0000000000000..0e631b1e333d7 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/lib/transform_metrics_explorer_data.ts @@ -0,0 +1,31 @@ +/* + * 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 { first } from 'lodash'; +import { MetricsExplorerResponse } from '../../../../common/http_api/metrics_explorer'; +import { MetricThresholdAlertParams, ExpressionChartSeries } from '../types'; + +export const transformMetricsExplorerData = ( + params: MetricThresholdAlertParams, + data: MetricsExplorerResponse | null +) => { + const { criteria } = params; + if (criteria && data) { + const firstSeries = first(data.series); + const series = firstSeries.rows.reduce((acc, row) => { + const { timestamp } = row; + criteria.forEach((item, index) => { + if (!acc[index]) { + acc[index] = []; + } + const value = (row[`metric_${index}`] as number) || 0; + acc[index].push({ timestamp, value }); + }); + return acc; + }, [] as ExpressionChartSeries); + return { id: firstSeries.id, series }; + } +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts new file mode 100644 index 0000000000000..af3baf191bed2 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + MetricExpressionParams, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../server/lib/alerting/metric_threshold/types'; +import { MetricsExplorerOptions } from '../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; +import { MetricsExplorerSeries } from '../../../common/http_api/metrics_explorer'; + +export interface AlertContextMeta { + currentOptions?: Partial<MetricsExplorerOptions>; + series?: MetricsExplorerSeries; +} + +export type TimeUnit = 's' | 'm' | 'h' | 'd'; +export type MetricExpression = Omit<MetricExpressionParams, 'metric'> & { + metric?: string; +}; + +export enum AGGREGATION_TYPES { + COUNT = 'count', + AVERAGE = 'avg', + SUM = 'sum', + MIN = 'min', + MAX = 'max', + RATE = 'rate', + CARDINALITY = 'cardinality', +} + +export interface MetricThresholdAlertParams { + criteria?: MetricExpression[]; + groupBy?: string; + filterQuery?: string; + sourceId?: string; +} + +export interface ExpressionChartRow { + timestamp: number; + value: number; +} + +export type ExpressionChartSeries = ExpressionChartRow[][]; + +export interface ExpressionChartData { + id: string; + series: ExpressionChartSeries; +} diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/alert_dropdown.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/alert_dropdown.tsx new file mode 100644 index 0000000000000..d2904206875c7 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/inventory/alert_dropdown.tsx @@ -0,0 +1,62 @@ +/* + * 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, useCallback, useMemo } from 'react'; +import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AlertFlyout } from './alert_flyout'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; + +export const InventoryAlertDropdown = () => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [flyoutVisible, setFlyoutVisible] = useState(false); + const kibana = useKibana(); + + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, [setPopoverOpen]); + + const openPopover = useCallback(() => { + setPopoverOpen(true); + }, [setPopoverOpen]); + + const menuItems = useMemo(() => { + return [ + <EuiContextMenuItem icon="bell" key="createLink" onClick={() => setFlyoutVisible(true)}> + <FormattedMessage + id="xpack.infra.alerting.createAlertButton" + defaultMessage="Create alert" + /> + </EuiContextMenuItem>, + <EuiContextMenuItem + icon="tableOfContents" + key="manageLink" + href={kibana.services?.application?.getUrlForApp( + 'kibana#/management/kibana/triggersActions/alerts' + )} + > + <FormattedMessage id="xpack.infra.alerting.manageAlerts" defaultMessage="Manage alerts" /> + </EuiContextMenuItem>, + ]; + }, [kibana.services]); + + return ( + <> + <EuiPopover + button={ + <EuiButtonEmpty iconSide={'right'} iconType={'arrowDown'} onClick={openPopover}> + <FormattedMessage id="xpack.infra.alerting.alertsButton" defaultMessage="Alerts" /> + </EuiButtonEmpty> + } + isOpen={popoverOpen} + closePopover={closePopover} + > + <EuiContextMenuPanel items={menuItems} /> + </EuiPopover> + <AlertFlyout setVisible={setFlyoutVisible} visible={flyoutVisible} /> + </> + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx new file mode 100644 index 0000000000000..7e85a2bdf7e9b --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/inventory/alert_flyout.tsx @@ -0,0 +1,53 @@ +/* + * 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, { useContext } from 'react'; +import { AlertsContextProvider, AlertAdd } from '../../../../../triggers_actions_ui/public'; +import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; +import { InfraWaffleMapOptions } from '../../../lib/lib'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; + +interface Props { + visible?: boolean; + options?: Partial<InfraWaffleMapOptions>; + nodeType?: InventoryItemType; + filter?: string; + setVisible: React.Dispatch<React.SetStateAction<boolean>>; +} + +export const AlertFlyout = (props: Props) => { + const { triggersActionsUI } = useContext(TriggerActionsContext); + const { services } = useKibana(); + + return ( + <> + {triggersActionsUI && ( + <AlertsContextProvider + value={{ + metadata: { options: props.options, nodeType: props.nodeType, filter: props.filter }, + toastNotifications: services.notifications?.toasts, + http: services.http, + docLinks: services.docLinks, + capabilities: services.application.capabilities, + actionTypeRegistry: triggersActionsUI.actionTypeRegistry, + alertTypeRegistry: triggersActionsUI.alertTypeRegistry, + }} + > + <AlertAdd + addFlyoutVisible={props.visible!} + setAddFlyoutVisibility={props.setVisible} + alertTypeId={METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID} + canChangeTrigger={false} + consumer={'metrics'} + /> + </AlertsContextProvider> + )} + </> + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx new file mode 100644 index 0000000000000..15cad770836bd --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/inventory/expression.tsx @@ -0,0 +1,498 @@ +/* + * 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, { useCallback, useMemo, useEffect, useState, ChangeEvent } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, + EuiText, + EuiFormRow, + EuiButtonEmpty, + EuiFieldSearch, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + Comparator, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../server/lib/alerting/metric_threshold/types'; +import { euiStyled } from '../../../../../observability/public'; +import { + ThresholdExpression, + ForLastExpression, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/common'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; +import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; +import { toMetricOpt } from '../../../pages/metrics/inventory_view/components/toolbars/toolbar_wrapper'; +import { sqsMetricTypes } from '../../../../common/inventory_models/aws_sqs/toolbar_items'; +import { ec2MetricTypes } from '../../../../common/inventory_models/aws_ec2/toolbar_items'; +import { s3MetricTypes } from '../../../../common/inventory_models/aws_s3/toolbar_items'; +import { rdsMetricTypes } from '../../../../common/inventory_models/aws_rds/toolbar_items'; +import { hostMetricTypes } from '../../../../common/inventory_models/host/toolbar_items'; +import { containerMetricTypes } from '../../../../common/inventory_models/container/toolbar_items'; +import { podMetricTypes } from '../../../../common/inventory_models/pod/toolbar_items'; +import { findInventoryModel } from '../../../../common/inventory_models'; +import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { InventoryMetricConditions } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; +import { MetricExpression } from './metric'; +import { NodeTypeExpression } from './node_type'; +import { InfraWaffleMapOptions } from '../../../lib/lib'; +import { convertKueryToElasticSearchQuery } from '../../../utils/kuery'; + +interface AlertContextMeta { + options?: Partial<InfraWaffleMapOptions>; + nodeType?: InventoryItemType; + filter?: string; +} + +interface Props { + errors: IErrorObject[]; + alertParams: { + criteria: InventoryMetricConditions[]; + nodeType: InventoryItemType; + groupBy?: string; + filterQuery?: string; + filterQueryText?: string; + sourceId?: string; + }; + alertsContext: AlertsContextValue<AlertContextMeta>; + setAlertParams(key: string, value: any): void; + setAlertProperty(key: string, value: any): void; +} + +type TimeUnit = 's' | 'm' | 'h' | 'd'; + +const defaultExpression = { + metric: 'cpu' as SnapshotMetricType, + comparator: Comparator.GT, + threshold: [], + timeSize: 1, + timeUnit: 'm', +} as InventoryMetricConditions; + +export const Expressions: React.FC<Props> = props => { + const { setAlertParams, alertParams, errors, alertsContext } = props; + const { source, createDerivedIndexPattern } = useSourceViaHttp({ + sourceId: 'default', + type: 'metrics', + fetch: alertsContext.http.fetch, + toastWarning: alertsContext.toastNotifications.addWarning, + }); + const [timeSize, setTimeSize] = useState<number | undefined>(1); + const [timeUnit, setTimeUnit] = useState<TimeUnit>('m'); + + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + createDerivedIndexPattern, + ]); + + const updateParams = useCallback( + (id, e: InventoryMetricConditions) => { + const exp = alertParams.criteria ? alertParams.criteria.slice() : []; + exp[id] = { ...exp[id], ...e }; + setAlertParams('criteria', exp); + }, + [setAlertParams, alertParams.criteria] + ); + + const addExpression = useCallback(() => { + const exp = alertParams.criteria.slice(); + exp.push(defaultExpression); + setAlertParams('criteria', exp); + }, [setAlertParams, alertParams.criteria]); + + const removeExpression = useCallback( + (id: number) => { + const exp = alertParams.criteria.slice(); + if (exp.length > 1) { + exp.splice(id, 1); + setAlertParams('criteria', exp); + } + }, + [setAlertParams, alertParams.criteria] + ); + + const onFilterChange = useCallback( + (filter: any) => { + setAlertParams('filterQueryText', filter || ''); + setAlertParams( + 'filterQuery', + convertKueryToElasticSearchQuery(filter, derivedIndexPattern) || '' + ); + }, + [derivedIndexPattern, setAlertParams] + ); + + const emptyError = useMemo(() => { + return { + aggField: [], + timeSizeUnit: [], + timeWindowSize: [], + }; + }, []); + + const updateTimeSize = useCallback( + (ts: number | undefined) => { + const criteria = alertParams.criteria.map(c => ({ + ...c, + timeSize: ts, + })); + setTimeSize(ts || undefined); + setAlertParams('criteria', criteria); + }, + [alertParams.criteria, setAlertParams] + ); + + const updateTimeUnit = useCallback( + (tu: string) => { + const criteria = alertParams.criteria.map(c => ({ + ...c, + timeUnit: tu, + })); + setTimeUnit(tu as TimeUnit); + setAlertParams('criteria', criteria); + }, + [alertParams.criteria, setAlertParams] + ); + + const updateNodeType = useCallback( + (nt: any) => { + setAlertParams('nodeType', nt); + }, + [setAlertParams] + ); + + const handleFieldSearchChange = useCallback( + (e: ChangeEvent<HTMLInputElement>) => onFilterChange(e.target.value), + [onFilterChange] + ); + + useEffect(() => { + const md = alertsContext.metadata; + if (!alertParams.nodeType) { + if (md && md.nodeType) { + setAlertParams('nodeType', md.nodeType); + } else { + setAlertParams('nodeType', 'host'); + } + } + + if (!alertParams.criteria) { + if (md && md.options) { + setAlertParams('criteria', [ + { + ...defaultExpression, + metric: md.options.metric!.type, + } as InventoryMetricConditions, + ]); + } else { + setAlertParams('criteria', [defaultExpression]); + } + } + + if (!alertParams.filterQuery) { + if (md && md.filter) { + setAlertParams('filterQueryText', md.filter); + setAlertParams( + 'filterQuery', + convertKueryToElasticSearchQuery(md.filter, derivedIndexPattern) || '' + ); + } + } + + if (!alertParams.sourceId) { + setAlertParams('sourceId', source?.id); + } + }, [alertsContext.metadata, derivedIndexPattern, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <> + <EuiSpacer size={'m'} /> + <EuiText size="xs"> + <h4> + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.conditions" + defaultMessage="Conditions" + /> + </h4> + </EuiText> + <StyledExpression> + <NodeTypeExpression + options={nodeTypes} + value={alertParams.nodeType || 'host'} + onChange={updateNodeType} + /> + </StyledExpression> + <EuiSpacer size={'xs'} /> + {alertParams.criteria && + alertParams.criteria.map((e, idx) => { + return ( + <ExpressionRow + nodeType={alertParams.nodeType} + canDelete={alertParams.criteria.length > 1} + remove={removeExpression} + addExpression={addExpression} + key={idx} // idx's don't usually make good key's but here the index has semantic meaning + expressionId={idx} + setAlertParams={updateParams} + errors={errors[idx] || emptyError} + expression={e || {}} + /> + ); + })} + + <ForLastExpression + timeWindowSize={timeSize} + timeWindowUnit={timeUnit} + errors={emptyError} + onChangeWindowSize={updateTimeSize} + onChangeWindowUnit={updateTimeUnit} + /> + + <div> + <EuiButtonEmpty + color={'primary'} + iconSide={'left'} + flush={'left'} + iconType={'plusInCircleFilled'} + onClick={addExpression} + > + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.addCondition" + defaultMessage="Add condition" + /> + </EuiButtonEmpty> + </div> + + <EuiSpacer size={'m'} /> + + <EuiFormRow + label={i18n.translate('xpack.infra.metrics.alertFlyout.filterLabel', { + defaultMessage: 'Filter (optional)', + })} + helpText={i18n.translate('xpack.infra.metrics.alertFlyout.filterHelpText', { + defaultMessage: 'Use a KQL expression to limit the scope of your alert trigger.', + })} + fullWidth + compressed + > + {(alertsContext.metadata && ( + <MetricsExplorerKueryBar + derivedIndexPattern={derivedIndexPattern} + onSubmit={onFilterChange} + onChange={onFilterChange} + value={alertParams.filterQueryText} + /> + )) || ( + <EuiFieldSearch + onChange={handleFieldSearchChange} + value={alertParams.filterQueryText} + fullWidth + /> + )} + </EuiFormRow> + + <EuiSpacer size={'m'} /> + </> + ); +}; + +interface ExpressionRowProps { + nodeType: InventoryItemType; + expressionId: number; + expression: Omit<InventoryMetricConditions, 'metric'> & { + metric?: SnapshotMetricType; + }; + errors: IErrorObject; + canDelete: boolean; + addExpression(): void; + remove(id: number): void; + setAlertParams(id: number, params: Partial<InventoryMetricConditions>): void; +} + +const StyledExpressionRow = euiStyled(EuiFlexGroup)` + display: flex; + flex-wrap: wrap; + margin: 0 -4px; +`; + +const StyledExpression = euiStyled.div` + padding: 0 4px; +`; + +export const ExpressionRow: React.FC<ExpressionRowProps> = props => { + const { setAlertParams, expression, errors, expressionId, remove, canDelete } = props; + const { metric, comparator = Comparator.GT, threshold = [] } = expression; + + const updateMetric = useCallback( + (m?: SnapshotMetricType) => { + setAlertParams(expressionId, { ...expression, metric: m }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateComparator = useCallback( + (c?: string) => { + setAlertParams(expressionId, { ...expression, comparator: c as Comparator | undefined }); + }, + [expressionId, expression, setAlertParams] + ); + + const updateThreshold = useCallback( + t => { + if (t.join() !== expression.threshold.join()) { + setAlertParams(expressionId, { ...expression, threshold: t }); + } + }, + [expressionId, expression, setAlertParams] + ); + + const ofFields = useMemo(() => { + let myMetrics = hostMetricTypes; + + switch (props.nodeType) { + case 'awsEC2': + myMetrics = ec2MetricTypes; + break; + case 'awsRDS': + myMetrics = rdsMetricTypes; + break; + case 'awsS3': + myMetrics = s3MetricTypes; + break; + case 'awsSQS': + myMetrics = sqsMetricTypes; + break; + case 'host': + myMetrics = hostMetricTypes; + break; + case 'pod': + myMetrics = podMetricTypes; + break; + case 'container': + myMetrics = containerMetricTypes; + break; + } + return myMetrics.map(toMetricOpt); + }, [props.nodeType]); + + return ( + <> + <EuiFlexGroup gutterSize="xs"> + <EuiFlexItem grow> + <StyledExpressionRow> + <StyledExpression> + <MetricExpression + metric={{ + value: metric!, + text: ofFields.find(v => v?.value === metric)?.text || '', + }} + metrics={ + ofFields.filter(m => m !== undefined && m.value !== undefined) as Array<{ + value: SnapshotMetricType; + text: string; + }> + } + onChange={updateMetric} + errors={errors} + /> + </StyledExpression> + <StyledExpression> + <ThresholdExpression + thresholdComparator={comparator || Comparator.GT} + threshold={threshold} + onChangeSelectedThresholdComparator={updateComparator} + onChangeSelectedThreshold={updateThreshold} + errors={errors} + /> + </StyledExpression> + {metric && ( + <StyledExpression> + <div style={{ display: 'flex', alignItems: 'center', height: '100%' }}> + <div>{metricUnit[metric]?.label || ''}</div> + </div> + </StyledExpression> + )} + </StyledExpressionRow> + </EuiFlexItem> + {canDelete && ( + <EuiFlexItem grow={false}> + <EuiButtonIcon + aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.removeCondition', { + defaultMessage: 'Remove condition', + })} + color={'danger'} + iconType={'trash'} + onClick={() => remove(expressionId)} + /> + </EuiFlexItem> + )} + </EuiFlexGroup> + <EuiSpacer size={'s'} /> + </> + ); +}; + +const getDisplayNameForType = (type: InventoryItemType) => { + const inventoryModel = findInventoryModel(type); + return inventoryModel.displayName; +}; + +export const nodeTypes: { [key: string]: any } = { + host: { + text: getDisplayNameForType('host'), + value: 'host', + }, + pod: { + text: getDisplayNameForType('pod'), + value: 'pod', + }, + container: { + text: getDisplayNameForType('container'), + value: 'container', + }, + awsEC2: { + text: getDisplayNameForType('awsEC2'), + value: 'awsEC2', + }, + awsS3: { + text: getDisplayNameForType('awsS3'), + value: 'awsS3', + }, + awsRDS: { + text: getDisplayNameForType('awsRDS'), + value: 'awsRDS', + }, + awsSQS: { + text: getDisplayNameForType('awsSQS'), + value: 'awsSQS', + }, +}; + +const metricUnit: Record<string, { label: string }> = { + count: { label: '' }, + cpu: { label: '%' }, + memory: { label: '%' }, + rx: { label: 'bits/s' }, + tx: { label: 'bits/s' }, + logRate: { label: '/s' }, + diskIOReadBytes: { label: 'bytes/s' }, + diskIOWriteBytes: { label: 'bytes/s' }, + s3BucketSize: { label: 'bytes' }, + s3TotalRequests: { label: '' }, + s3NumberOfObjects: { label: '' }, + s3UploadBytes: { label: 'bytes' }, + s3DownloadBytes: { label: 'bytes' }, + sqsOldestMessage: { label: 'seconds' }, +}; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/metric.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/metric.tsx new file mode 100644 index 0000000000000..faafdf1b81eed --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/inventory/metric.tsx @@ -0,0 +1,150 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiExpression, + EuiPopover, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiComboBox, +} from '@elastic/eui'; +import { EuiPopoverTitle, EuiButtonIcon } from '@elastic/eui'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { SnapshotMetricType } from '../../../../common/inventory_models/types'; + +interface Props { + metric?: { value: SnapshotMetricType; text: string }; + metrics: Array<{ value: string; text: string }>; + errors: IErrorObject; + onChange: (metric: SnapshotMetricType) => void; + popupPosition?: + | 'upCenter' + | 'upLeft' + | 'upRight' + | 'downCenter' + | 'downLeft' + | 'downRight' + | 'leftCenter' + | 'leftUp' + | 'leftDown' + | 'rightCenter' + | 'rightUp' + | 'rightDown'; +} + +export const MetricExpression = ({ metric, metrics, errors, onChange, popupPosition }: Props) => { + const [aggFieldPopoverOpen, setAggFieldPopoverOpen] = useState(false); + const firstFieldOption = { + text: i18n.translate('xpack.infra.metrics.alertFlyout.expression.metric.selectFieldLabel', { + defaultMessage: 'Select a metric', + }), + value: '', + }; + + const availablefieldsOptions = metrics.map(m => { + return { label: m.text, value: m.value }; + }, []); + + return ( + <EuiPopover + id="aggFieldPopover" + button={ + <EuiExpression + description={i18n.translate( + 'xpack.infra.metrics.alertFlyout.expression.metric.whenLabel', + { + defaultMessage: 'When', + } + )} + value={metric?.text || firstFieldOption.text} + isActive={aggFieldPopoverOpen || !metric} + onClick={() => { + setAggFieldPopoverOpen(true); + }} + color={metric ? 'secondary' : 'danger'} + /> + } + isOpen={aggFieldPopoverOpen} + closePopover={() => { + setAggFieldPopoverOpen(false); + }} + withTitle + anchorPosition={popupPosition ?? 'downRight'} + zIndex={8000} + > + <div> + <ClosablePopoverTitle onClose={() => setAggFieldPopoverOpen(false)}> + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.expression.metric.popoverTitle" + defaultMessage="Metric" + /> + </ClosablePopoverTitle> + <EuiFlexGroup> + <EuiFlexItem grow={false} className="actOf__aggFieldContainer"> + <EuiFormRow + fullWidth + isInvalid={errors.metric.length > 0 && metric !== undefined} + error={errors.metric} + > + <EuiComboBox + fullWidth + singleSelection={{ asPlainText: true }} + data-test-subj="availablefieldsOptionsComboBox" + isInvalid={errors.metric.length > 0 && metric !== undefined} + placeholder={firstFieldOption.text} + options={availablefieldsOptions} + noSuggestions={!availablefieldsOptions.length} + selectedOptions={ + metric ? availablefieldsOptions.filter(a => a.value === metric.value) : [] + } + renderOption={(o: any) => o.label} + onChange={selectedOptions => { + if (selectedOptions.length > 0) { + onChange(selectedOptions[0].value as SnapshotMetricType); + setAggFieldPopoverOpen(false); + } + }} + /> + </EuiFormRow> + </EuiFlexItem> + </EuiFlexGroup> + </div> + </EuiPopover> + ); +}; + +interface ClosablePopoverTitleProps { + children: JSX.Element; + onClose: () => void; +} + +export const ClosablePopoverTitle = ({ children, onClose }: ClosablePopoverTitleProps) => { + return ( + <EuiPopoverTitle> + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem>{children}</EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + iconType="cross" + color="danger" + aria-label={i18n.translate( + 'xpack.infra.metrics.expressionItems.components.closablePopoverTitle.closeLabel', + { + defaultMessage: 'Close', + } + )} + onClick={() => onClose()} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPopoverTitle> + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts b/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.ts new file mode 100644 index 0000000000000..b7abaf5b36373 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/inventory/metric_inventory_threshold_alert_type.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 { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; +import { Expressions } from './expression'; +import { validateMetricThreshold } from './validation'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; + +export function getInventoryMetricAlertType(): AlertTypeModel { + return { + id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + name: i18n.translate('xpack.infra.metrics.inventory.alertFlyout.alertName', { + defaultMessage: 'Inventory', + }), + iconClass: 'bell', + alertParamsExpression: Expressions, + validate: validateMetricThreshold, + defaultActionMessage: i18n.translate( + 'xpack.infra.metrics.alerting.inventory.threshold.defaultActionMessage', + { + defaultMessage: `\\{\\{alertName\\}\\} - \\{\\{context.group\\}\\} + +\\{\\{context.metricOf.condition0\\}\\} has crossed a threshold of \\{\\{context.thresholdOf.condition0\\}\\} +Current value is \\{\\{context.valueOf.condition0\\}\\} +`, + } + ), + }; +} diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/node_type.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/node_type.tsx new file mode 100644 index 0000000000000..1623fc4e24dcb --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/inventory/node_type.tsx @@ -0,0 +1,115 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiExpression, EuiPopover, EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui'; +import { EuiPopoverTitle, EuiButtonIcon } from '@elastic/eui'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; + +interface WhenExpressionProps { + value: InventoryItemType; + options: { [key: string]: { text: string; value: InventoryItemType } }; + onChange: (value: InventoryItemType) => void; + popupPosition?: + | 'upCenter' + | 'upLeft' + | 'upRight' + | 'downCenter' + | 'downLeft' + | 'downRight' + | 'leftCenter' + | 'leftUp' + | 'leftDown' + | 'rightCenter' + | 'rightUp' + | 'rightDown'; +} + +export const NodeTypeExpression = ({ + value, + options, + onChange, + popupPosition, +}: WhenExpressionProps) => { + const [aggTypePopoverOpen, setAggTypePopoverOpen] = useState(false); + + return ( + <EuiPopover + button={ + <EuiExpression + data-test-subj="nodeTypeExpression" + description={i18n.translate( + 'xpack.infra.metrics.alertFlyout.expression.for.descriptionLabel', + { + defaultMessage: 'For', + } + )} + value={options[value].text} + isActive={aggTypePopoverOpen} + onClick={() => { + setAggTypePopoverOpen(true); + }} + /> + } + isOpen={aggTypePopoverOpen} + closePopover={() => { + setAggTypePopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition={popupPosition ?? 'downLeft'} + > + <div> + <ClosablePopoverTitle onClose={() => setAggTypePopoverOpen(false)}> + <FormattedMessage + id="xpack.infra.metrics.alertFlyout.expression.for.popoverTitle" + defaultMessage="Inventory Type" + /> + </ClosablePopoverTitle> + <EuiSelect + data-test-subj="forExpressionSelect" + value={value} + fullWidth + onChange={e => { + onChange(e.target.value as InventoryItemType); + setAggTypePopoverOpen(false); + }} + options={Object.values(options).map(o => o)} + /> + </div> + </EuiPopover> + ); +}; + +interface ClosablePopoverTitleProps { + children: JSX.Element; + onClose: () => void; +} + +export const ClosablePopoverTitle = ({ children, onClose }: ClosablePopoverTitleProps) => { + return ( + <EuiPopoverTitle> + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem>{children}</EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + iconType="cross" + color="danger" + aria-label={i18n.translate( + 'xpack.infra.metrics.expressionItems.components.closablePopoverTitle.closeLabel', + { + defaultMessage: 'Close', + } + )} + onClick={() => onClose()} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPopoverTitle> + ); +}; diff --git a/x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx b/x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx new file mode 100644 index 0000000000000..803893dd5a323 --- /dev/null +++ b/x-pack/plugins/infra/public/components/alerting/inventory/validation.tsx @@ -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. + */ + +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { MetricExpressionParams } from '../../../../server/lib/alerting/metric_threshold/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; + +export function validateMetricThreshold({ + criteria, +}: { + criteria: MetricExpressionParams[]; +}): ValidationResult { + const validationResult = { errors: {} }; + const errors: { + [id: string]: { + timeSizeUnit: string[]; + timeWindowSize: string[]; + threshold0: string[]; + threshold1: string[]; + metric: string[]; + }; + } = {}; + validationResult.errors = errors; + + if (!criteria || !criteria.length) { + return validationResult; + } + + criteria.forEach((c, idx) => { + // Create an id for each criteria, so we can map errors to specific criteria. + const id = idx.toString(); + + errors[id] = errors[id] || { + timeSizeUnit: [], + timeWindowSize: [], + threshold0: [], + threshold1: [], + metric: [], + }; + + if (!c.threshold || !c.threshold.length) { + errors[id].threshold0.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { + defaultMessage: 'Threshold is required.', + }) + ); + } + + if (c.comparator === 'between' && (!c.threshold || c.threshold.length < 2)) { + errors[id].threshold1.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.thresholdRequired', { + defaultMessage: 'Threshold is required.', + }) + ); + } + + if (!c.timeSize) { + errors[id].timeWindowSize.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.timeRequred', { + defaultMessage: 'Time size is Required.', + }) + ); + } + + if (!c.metric && c.aggType !== 'count') { + errors[id].metric.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.metricRequired', { + defaultMessage: 'Metric is required.', + }) + ); + } + }); + + return validationResult; +} 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 b18c2e5b8d69c..bd889bff8cd0e 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,10 +24,13 @@ export const AlertFlyout = (props: Props) => { {triggersActionsUI && ( <AlertsContextProvider value={{ - metadata: {}, + metadata: { + isInternal: true, + }, toastNotifications: services.notifications?.toasts, http: services.http, docLinks: services.docLinks, + capabilities: services.application.capabilities, actionTypeRegistry: triggersActionsUI.actionTypeRegistry, alertTypeRegistry: triggersActionsUI.alertTypeRegistry, }} diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx index 308165ce08a9b..f80781f5a68d7 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/document_count.tsx @@ -56,6 +56,11 @@ export const DocumentCount: React.FC<Props> = ({ comparator, value, updateCount, values: { value }, }); + const documentCountSuffix = i18n.translate('xpack.infra.logs.alertFlyout.documentCountSuffix', { + defaultMessage: '{value, plural, one {occurs} other {occur}}', + values: { value }, + }); + return ( <EuiFlexGroup gutterSize="s"> <EuiFlexItem grow={false}> @@ -122,6 +127,10 @@ export const DocumentCount: React.FC<Props> = ({ comparator, value, updateCount, </div> </EuiPopover> </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiExpression description={documentCountSuffix} value="" /> + </EuiFlexItem> </EuiFlexGroup> ); }; 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 3aed0db53bf2c..8bdffbeb36f3a 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<LogDocumentCountAlertParams>; setAlertParams(key: string, value: any): void; setAlertProperty(key: string, value: any): void; + alertsContext: AlertsContextValue<LogsContextMeta>; } const DEFAULT_CRITERIA = { field: 'log.level', comparator: Comparator.EQ, value: 'error' }; @@ -48,32 +58,92 @@ const DEFAULT_EXPRESSION = { }; export const ExpressionEditor: React.FC<Props> = props => { + const isInternal = props.alertsContext.metadata?.isInternal; + const [sourceId] = useSourceId(); + + return ( + <> + {isInternal ? ( + <SourceStatusWrapper {...props}> + <Editor {...props} /> + </SourceStatusWrapper> + ) : ( + <LogSourceProvider sourceId={sourceId} fetch={props.alertsContext.http.fetch}> + <SourceStatusWrapper {...props}> + <Editor {...props} /> + </SourceStatusWrapper> + </LogSourceProvider> + )} + </> + ); +}; + +export const SourceStatusWrapper: React.FC<Props> = props => { + const { + initialize, + isLoadingSourceStatus, + isUninitialized, + hasFailedLoadingSourceStatus, + loadSourceStatus, + } = useLogSourceContext(); + const { children } = props; + + useMount(() => { + initialize(); + }); + + return ( + <> + {isLoadingSourceStatus || isUninitialized ? ( + <div> + <EuiSpacer size="m" /> + <EuiLoadingSpinner size="l" /> + <EuiSpacer size="m" /> + </div> + ) : hasFailedLoadingSourceStatus ? ( + <EuiCallOut + title={i18n.translate('xpack.infra.logs.alertFlyout.sourceStatusError', { + defaultMessage: 'Sorry, there was a problem loading field information', + })} + color="danger" + iconType="alert" + > + <EuiButton onClick={loadSourceStatus} iconType="refresh"> + {i18n.translate('xpack.infra.logs.alertFlyout.sourceStatusErrorTryAgain', { + defaultMessage: 'Try again', + })} + </EuiButton> + </EuiCallOut> + ) : ( + children + )} + </> + ); +}; + +export const Editor: React.FC<Props> = props => { const { setAlertParams, alertParams, errors } = props; - const { createDerivedIndexPattern } = useSource({ sourceId: 'default' }); const [timeSize, setTimeSize] = useState<number | undefined>(1); const [timeUnit, setTimeUnit] = useState<TimeUnit>('m'); const [hasSetDefaults, setHasSetDefaults] = useState<boolean>(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> = 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/alerting/metrics/alert_dropdown.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx deleted file mode 100644 index bb664f4067662..0000000000000 --- a/x-pack/plugins/infra/public/components/alerting/metrics/alert_dropdown.tsx +++ /dev/null @@ -1,62 +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, { useState, useCallback, useMemo } from 'react'; -import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { AlertFlyout } from './alert_flyout'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; - -export const AlertDropdown = () => { - const [popoverOpen, setPopoverOpen] = useState(false); - const [flyoutVisible, setFlyoutVisible] = useState(false); - const kibana = useKibana(); - - const closePopover = useCallback(() => { - setPopoverOpen(false); - }, [setPopoverOpen]); - - const openPopover = useCallback(() => { - setPopoverOpen(true); - }, [setPopoverOpen]); - - const menuItems = useMemo(() => { - return [ - <EuiContextMenuItem icon="bell" key="createLink" onClick={() => setFlyoutVisible(true)}> - <FormattedMessage - id="xpack.infra.alerting.createAlertButton" - defaultMessage="Create alert" - /> - </EuiContextMenuItem>, - <EuiContextMenuItem - icon="tableOfContents" - key="manageLink" - href={kibana.services?.application?.getUrlForApp( - 'kibana#/management/kibana/triggersActions/alerts' - )} - > - <FormattedMessage id="xpack.infra.alerting.manageAlerts" defaultMessage="Manage alerts" /> - </EuiContextMenuItem>, - ]; - }, [kibana.services]); - - return ( - <> - <EuiPopover - button={ - <EuiButtonEmpty iconSide={'right'} iconType={'arrowDown'} onClick={openPopover}> - <FormattedMessage id="xpack.infra.alerting.alertsButton" defaultMessage="Alerts" /> - </EuiButtonEmpty> - } - isOpen={popoverOpen} - closePopover={closePopover} - > - <EuiContextMenuPanel items={menuItems} /> - </EuiPopover> - <AlertFlyout setVisible={setFlyoutVisible} visible={flyoutVisible} /> - </> - ); -}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx deleted file mode 100644 index 38709c117c817..0000000000000 --- a/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx +++ /dev/null @@ -1,54 +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, { useContext } from 'react'; -import { AlertsContextProvider, AlertAdd } from '../../../../../triggers_actions_ui/public'; -import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types'; -import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer'; -import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; - -interface Props { - visible?: boolean; - options?: Partial<MetricsExplorerOptions>; - series?: MetricsExplorerSeries; - setVisible: React.Dispatch<React.SetStateAction<boolean>>; -} - -export const AlertFlyout = (props: Props) => { - const { triggersActionsUI } = useContext(TriggerActionsContext); - const { services } = useKibana(); - - return ( - <> - {triggersActionsUI && ( - <AlertsContextProvider - value={{ - metadata: { - currentOptions: props.options, - series: props.series, - }, - toastNotifications: services.notifications?.toasts, - http: services.http, - docLinks: services.docLinks, - actionTypeRegistry: triggersActionsUI.actionTypeRegistry, - alertTypeRegistry: triggersActionsUI.alertTypeRegistry, - }} - > - <AlertAdd - addFlyoutVisible={props.visible!} - setAddFlyoutVisibility={props.setVisible} - alertTypeId={METRIC_THRESHOLD_ALERT_TYPE_ID} - canChangeTrigger={false} - consumer={'metrics'} - /> - </AlertsContextProvider> - )} - </> - ); -}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx deleted file mode 100644 index d4d53b81109c6..0000000000000 --- a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx +++ /dev/null @@ -1,507 +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, { ChangeEvent, useCallback, useMemo, useEffect, useState } from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiButtonIcon, - EuiSpacer, - EuiText, - EuiFormRow, - EuiButtonEmpty, - EuiFieldSearch, -} from '@elastic/eui'; -import { IFieldType } from 'src/plugins/data/public'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { - MetricExpressionParams, - Comparator, - Aggregators, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../server/lib/alerting/metric_threshold/types'; -import { euiStyled } from '../../../../../observability/public'; -import { - WhenExpression, - OfExpression, - ThresholdExpression, - ForLastExpression, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../triggers_actions_ui/public/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { builtInComparators } from '../../../../../triggers_actions_ui/public/common/constants'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/application/context/alerts_context'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer'; -import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; -import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; -import { MetricsExplorerGroupBy } from '../../../pages/metrics/metrics_explorer/components/group_by'; -import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; - -interface AlertContextMeta { - currentOptions?: Partial<MetricsExplorerOptions>; - series?: MetricsExplorerSeries; -} - -interface Props { - errors: IErrorObject[]; - alertParams: { - criteria: MetricExpression[]; - groupBy?: string; - filterQuery?: string; - sourceId?: string; - }; - alertsContext: AlertsContextValue<AlertContextMeta>; - setAlertParams(key: string, value: any): void; - setAlertProperty(key: string, value: any): void; -} - -type TimeUnit = 's' | 'm' | 'h' | 'd'; -type MetricExpression = Omit<MetricExpressionParams, 'metric'> & { - metric?: string; -}; - -const defaultExpression = { - aggType: Aggregators.AVERAGE, - comparator: Comparator.GT, - threshold: [], - timeSize: 1, - timeUnit: 'm', -} as MetricExpression; - -const customComparators = { - ...builtInComparators, - [Comparator.OUTSIDE_RANGE]: { - text: i18n.translate('xpack.infra.metrics.alertFlyout.outsideRangeLabel', { - defaultMessage: 'Is not between', - }), - value: Comparator.OUTSIDE_RANGE, - requiredValues: 2, - }, -}; - -export const Expressions: React.FC<Props> = props => { - const { setAlertParams, alertParams, errors, alertsContext } = props; - const { source, createDerivedIndexPattern } = useSourceViaHttp({ - sourceId: 'default', - type: 'metrics', - fetch: alertsContext.http.fetch, - toastWarning: alertsContext.toastNotifications.addWarning, - }); - const [timeSize, setTimeSize] = useState<number | undefined>(1); - const [timeUnit, setTimeUnit] = useState<TimeUnit>('m'); - - const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ - createDerivedIndexPattern, - ]); - - const options = useMemo<MetricsExplorerOptions>(() => { - if (alertsContext.metadata?.currentOptions?.metrics) { - return alertsContext.metadata.currentOptions as MetricsExplorerOptions; - } else { - return { - metrics: [], - aggregation: 'avg', - }; - } - }, [alertsContext.metadata]); - - const updateParams = useCallback( - (id, e: MetricExpression) => { - const exp = alertParams.criteria ? alertParams.criteria.slice() : []; - exp[id] = { ...exp[id], ...e }; - setAlertParams('criteria', exp); - }, - [setAlertParams, alertParams.criteria] - ); - - const addExpression = useCallback(() => { - const exp = alertParams.criteria.slice(); - exp.push(defaultExpression); - setAlertParams('criteria', exp); - }, [setAlertParams, alertParams.criteria]); - - const removeExpression = useCallback( - (id: number) => { - const exp = alertParams.criteria.slice(); - if (exp.length > 1) { - exp.splice(id, 1); - setAlertParams('criteria', exp); - } - }, - [setAlertParams, alertParams.criteria] - ); - - const onFilterQuerySubmit = useCallback( - (filter: any) => { - setAlertParams('filterQuery', filter); - }, - [setAlertParams] - ); - - const onGroupByChange = useCallback( - (group: string | null) => { - setAlertParams('groupBy', group || ''); - }, - [setAlertParams] - ); - - const emptyError = useMemo(() => { - return { - aggField: [], - timeSizeUnit: [], - timeWindowSize: [], - }; - }, []); - - const updateTimeSize = useCallback( - (ts: number | undefined) => { - const criteria = alertParams.criteria.map(c => ({ - ...c, - timeSize: ts, - })); - setTimeSize(ts || undefined); - setAlertParams('criteria', criteria); - }, - [alertParams.criteria, setAlertParams] - ); - - const updateTimeUnit = useCallback( - (tu: string) => { - const criteria = alertParams.criteria.map(c => ({ - ...c, - timeUnit: tu, - })); - setTimeUnit(tu as TimeUnit); - setAlertParams('criteria', criteria); - }, - [alertParams.criteria, setAlertParams] - ); - - useEffect(() => { - const md = alertsContext.metadata; - if (md) { - if (md.currentOptions?.metrics) { - setAlertParams( - 'criteria', - md.currentOptions.metrics.map(metric => ({ - metric: metric.field, - comparator: Comparator.GT, - threshold: [], - timeSize, - timeUnit, - aggType: metric.aggregation, - })) - ); - } else { - setAlertParams('criteria', [defaultExpression]); - } - - if (md.currentOptions) { - if (md.currentOptions.filterQuery) { - setAlertParams('filterQuery', md.currentOptions.filterQuery); - } else if (md.currentOptions.groupBy && md.series) { - const filter = `${md.currentOptions.groupBy}: "${md.series.id}"`; - setAlertParams('filterQuery', filter); - } - - setAlertParams('groupBy', md.currentOptions.groupBy); - } - setAlertParams('sourceId', source?.id); - } else { - if (!alertParams.criteria) { - setAlertParams('criteria', [defaultExpression]); - } - if (!alertParams.sourceId) { - setAlertParams('sourceId', source?.id || 'default'); - } - } - }, [alertsContext.metadata, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps - - const handleFieldSearchChange = useCallback( - (e: ChangeEvent<HTMLInputElement>) => onFilterQuerySubmit(e.target.value), - [onFilterQuerySubmit] - ); - - return ( - <> - <EuiSpacer size={'m'} /> - <EuiText size="xs"> - <h4> - <FormattedMessage - id="xpack.infra.metrics.alertFlyout.conditions" - defaultMessage="Conditions" - /> - </h4> - </EuiText> - <EuiSpacer size={'xs'} /> - {alertParams.criteria && - alertParams.criteria.map((e, idx) => { - return ( - <ExpressionRow - canDelete={alertParams.criteria.length > 1} - fields={derivedIndexPattern.fields} - remove={removeExpression} - addExpression={addExpression} - key={idx} // idx's don't usually make good key's but here the index has semantic meaning - expressionId={idx} - setAlertParams={updateParams} - errors={errors[idx] || emptyError} - expression={e || {}} - /> - ); - })} - - <ForLastExpression - timeWindowSize={timeSize} - timeWindowUnit={timeUnit} - errors={emptyError} - onChangeWindowSize={updateTimeSize} - onChangeWindowUnit={updateTimeUnit} - /> - - <div> - <EuiButtonEmpty - color={'primary'} - iconSide={'left'} - flush={'left'} - iconType={'plusInCircleFilled'} - onClick={addExpression} - > - <FormattedMessage - id="xpack.infra.metrics.alertFlyout.addCondition" - defaultMessage="Add condition" - /> - </EuiButtonEmpty> - </div> - - <EuiSpacer size={'m'} /> - - <EuiFormRow - label={i18n.translate('xpack.infra.metrics.alertFlyout.filterLabel', { - defaultMessage: 'Filter (optional)', - })} - helpText={i18n.translate('xpack.infra.metrics.alertFlyout.filterHelpText', { - defaultMessage: 'Use a KQL expression to limit the scope of your alert trigger.', - })} - fullWidth - compressed - > - {(alertsContext.metadata && ( - <MetricsExplorerKueryBar - derivedIndexPattern={derivedIndexPattern} - onSubmit={onFilterQuerySubmit} - value={alertParams.filterQuery} - /> - )) || ( - <EuiFieldSearch - onChange={handleFieldSearchChange} - value={alertParams.filterQuery} - fullWidth - /> - )} - </EuiFormRow> - - <EuiSpacer size={'m'} /> - <EuiFormRow - label={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerText', { - defaultMessage: 'Create alert per (optional)', - })} - helpText={i18n.translate('xpack.infra.metrics.alertFlyout.createAlertPerHelpText', { - defaultMessage: - 'Create an alert for every unique value. For example: "host.id" or "cloud.region".', - })} - fullWidth - compressed - > - <MetricsExplorerGroupBy - onChange={onGroupByChange} - fields={derivedIndexPattern.fields} - options={{ - ...options, - groupBy: alertParams.groupBy || undefined, - }} - /> - </EuiFormRow> - </> - ); -}; - -interface ExpressionRowProps { - fields: IFieldType[]; - expressionId: number; - expression: MetricExpression; - errors: IErrorObject; - canDelete: boolean; - addExpression(): void; - remove(id: number): void; - setAlertParams(id: number, params: MetricExpression): void; -} - -const StyledExpressionRow = euiStyled(EuiFlexGroup)` - display: flex; - flex-wrap: wrap; - margin: 0 -4px; -`; - -const StyledExpression = euiStyled.div` - padding: 0 4px; -`; - -export const ExpressionRow: React.FC<ExpressionRowProps> = props => { - const { setAlertParams, expression, errors, expressionId, remove, fields, canDelete } = props; - const { - aggType = Aggregators.MAX, - metric, - comparator = Comparator.GT, - threshold = [], - } = expression; - - const updateAggType = useCallback( - (at: string) => { - setAlertParams(expressionId, { - ...expression, - aggType: at as MetricExpression['aggType'], - metric: at === 'count' ? undefined : expression.metric, - }); - }, - [expressionId, expression, setAlertParams] - ); - - const updateMetric = useCallback( - (m?: MetricExpression['metric']) => { - setAlertParams(expressionId, { ...expression, metric: m }); - }, - [expressionId, expression, setAlertParams] - ); - - const updateComparator = useCallback( - (c?: string) => { - setAlertParams(expressionId, { ...expression, comparator: c as Comparator }); - }, - [expressionId, expression, setAlertParams] - ); - - const updateThreshold = useCallback( - t => { - if (t.join() !== expression.threshold.join()) { - setAlertParams(expressionId, { ...expression, threshold: t }); - } - }, - [expressionId, expression, setAlertParams] - ); - - return ( - <> - <EuiFlexGroup gutterSize="xs"> - <EuiFlexItem grow> - <StyledExpressionRow> - <StyledExpression> - <WhenExpression - customAggTypesOptions={aggregationType} - aggType={aggType} - onChangeSelectedAggType={updateAggType} - /> - </StyledExpression> - {aggType !== 'count' && ( - <StyledExpression> - <OfExpression - customAggTypesOptions={aggregationType} - aggField={metric} - fields={fields.map(f => ({ - normalizedType: f.type, - name: f.name, - }))} - aggType={aggType} - errors={errors} - onChangeSelectedAggField={updateMetric} - /> - </StyledExpression> - )} - <StyledExpression> - <ThresholdExpression - thresholdComparator={comparator || Comparator.GT} - threshold={threshold} - customComparators={customComparators} - onChangeSelectedThresholdComparator={updateComparator} - onChangeSelectedThreshold={updateThreshold} - errors={errors} - /> - </StyledExpression> - </StyledExpressionRow> - </EuiFlexItem> - {canDelete && ( - <EuiFlexItem grow={false}> - <EuiButtonIcon - aria-label={i18n.translate('xpack.infra.metrics.alertFlyout.removeCondition', { - defaultMessage: 'Remove condition', - })} - color={'danger'} - iconType={'trash'} - onClick={() => remove(expressionId)} - /> - </EuiFlexItem> - )} - </EuiFlexGroup> - <EuiSpacer size={'s'} /> - </> - ); -}; - -export const aggregationType: { [key: string]: any } = { - avg: { - text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.avg', { - defaultMessage: 'Average', - }), - fieldRequired: true, - validNormalizedTypes: ['number'], - value: Aggregators.AVERAGE, - }, - max: { - text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.max', { - defaultMessage: 'Max', - }), - fieldRequired: true, - validNormalizedTypes: ['number', 'date'], - value: Aggregators.MAX, - }, - min: { - text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.min', { - defaultMessage: 'Min', - }), - fieldRequired: true, - validNormalizedTypes: ['number', 'date'], - value: Aggregators.MIN, - }, - cardinality: { - text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.cardinality', { - defaultMessage: 'Cardinality', - }), - fieldRequired: false, - value: Aggregators.CARDINALITY, - validNormalizedTypes: ['number'], - }, - rate: { - text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.rate', { - defaultMessage: 'Rate', - }), - fieldRequired: false, - value: Aggregators.RATE, - validNormalizedTypes: ['number'], - }, - count: { - text: i18n.translate('xpack.infra.metrics.alertFlyout.aggregationText.count', { - defaultMessage: 'Document count', - }), - fieldRequired: false, - value: Aggregators.COUNT, - validNormalizedTypes: ['number'], - }, -}; diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts b/x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts deleted file mode 100644 index 04179e34222c5..0000000000000 --- a/x-pack/plugins/infra/public/components/alerting/metrics/metric_threshold_alert_type.ts +++ /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 { i18n } from '@kbn/i18n'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AlertTypeModel } from '../../../../../triggers_actions_ui/public/types'; -import { Expressions } from './expression'; -import { validateMetricThreshold } from './validation'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types'; - -export function getAlertType(): AlertTypeModel { - return { - id: METRIC_THRESHOLD_ALERT_TYPE_ID, - name: i18n.translate('xpack.infra.metrics.alertFlyout.alertName', { - defaultMessage: 'Metric threshold', - }), - iconClass: 'bell', - alertParamsExpression: Expressions, - validate: validateMetricThreshold, - defaultActionMessage: i18n.translate( - 'xpack.infra.metrics.alerting.threshold.defaultActionMessage', - { - defaultMessage: `\\{\\{alertName\\}\\} - \\{\\{context.group\\}\\} - -\\{\\{context.metricOf.condition0\\}\\} has crossed a threshold of \\{\\{context.thresholdOf.condition0\\}\\} -Current value is \\{\\{context.valueOf.condition0\\}\\} -`, - } - ), - }; -} diff --git a/x-pack/plugins/infra/public/components/loading_page.tsx b/x-pack/plugins/infra/public/components/loading_page.tsx index ae179c6542c13..9d37fed45b583 100644 --- a/x-pack/plugins/infra/public/components/loading_page.tsx +++ b/x-pack/plugins/infra/public/components/loading_page.tsx @@ -27,7 +27,7 @@ export const LoadingPage = ({ message }: LoadingPageProps) => ( <EuiFlexItem grow={false}> <EuiLoadingSpinner size="xl" /> </EuiFlexItem> - <EuiFlexItem>{message}</EuiFlexItem> + <EuiFlexItem data-test-subj="loadingMessage">{message}</EuiFlexItem> </EuiFlexGroup> </EuiPageContent> </EuiPageBody> 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 649858f657bfe..06dbf5315b83a 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<HTMLInputElement>) => { + 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 = ( - <EuiCheckbox - key={index.name} - id={index.name} - label={<EuiCode>{index.name}</EuiCode>} - onChange={handleCheckboxChange} - checked={index.validity === 'valid' && index.isSelected} - disabled={disabled || index.validity === 'invalid'} - /> - ); - - return index.validity === 'valid' ? ( - checkbox - ) : ( - <div key={index.name}> - <EuiToolTip content={formatValidationError(index.errors)}>{checkbox}</EuiToolTip> - </div> - ); - }), - [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={ <FormattedMessage id="xpack.infra.analysisSetup.indicesSelectionDescription" - defaultMessage="By default, Machine Learning analyzes log messages in all log indices configured for the source. You can choose to only analyze a subset of the index names. Every selected index name must match at least one index with log entries." + defaultMessage="By default, Machine Learning analyzes log messages in all log indices configured for the source. You can choose to only analyze a subset of the index names. Every selected index name must match at least one index with log entries. You can also choose to only include a certain subset of datasets. Note that the dataset filter applies to all selected indices." /> } > <LoadingOverlayWrapper isLoading={isValidating}> <EuiFormRow fullWidth isInvalid={!valid} label={indicesSelectionLabel} labelType="legend"> - <>{choices}</> + <> + {indices.map(index => ( + <IndexSetupRow + index={index} + isDisabled={disabled} + key={index.name} + onChangeIsSelected={changeIsIndexSelected} + onChangeDatasetFilter={changeDatasetFilter} + /> + ))} + </> </EuiFormRow> </LoadingOverlayWrapper> </EuiDescribedFormGroup> @@ -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 ( - <p key={`${error.error}-${error.index}`}> - <FormattedMessage - id="xpack.infra.analysisSetup.indicesSelectionIndexNotFound" - defaultMessage="No indices match the pattern {index}" - values={{ index: <EuiCode>{error.index}</EuiCode> }} - /> - </p> - ); - - case 'FIELD_NOT_FOUND': - return ( - <p key={`${error.error}-${error.index}-${error.field}`}> - <FormattedMessage - id="xpack.infra.analysisSetup.indicesSelectionNoTimestampField" - defaultMessage="At least one index matching {index} lacks a required field {field}." - values={{ - index: <EuiCode>{error.index}</EuiCode>, - field: <EuiCode>{error.field}</EuiCode>, - }} - /> - </p> - ); - - case 'FIELD_NOT_VALID': - return ( - <p key={`${error.error}-${error.index}-${error.field}`}> - <FormattedMessage - id="xpack.infra.analysisSetup.indicesSelectionTimestampNotValid" - defaultMessage="At least one index matching {index} has a field called {field} without the correct type." - values={{ - index: <EuiCode>{error.index}</EuiCode>, - field: <EuiCode>{error.field}</EuiCode>, - }} - /> - </p> - ); - - 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 0000000000000..b37c68f837876 --- /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 = ( + <EuiFilterButton disabled={isDisabled} isSelected={isVisible} onClick={show}> + <FormattedMessage + id="xpack.infra.analysisSetup.indexDatasetFilterIncludeAllButtonLabel" + defaultMessage="{includeType, select, includeAll {All datasets} includeSome {{includedDatasetCount, plural, one {# dataset} other {# datasets}}}}" + values={{ + includeType: datasetFilter.type, + includedDatasetCount: + datasetFilter.type === 'includeSome' ? datasetFilter.datasets.length : 0, + }} + /> + </EuiFilterButton> + ); + + return ( + <EuiFilterGroup> + <EuiPopover + button={datasetFilterButton} + closePopover={hide} + isOpen={isVisible} + panelPaddingSize="none" + > + <EuiSelectable onChange={changeDatasetFilter} options={selectableOptions} searchable> + {(list, search) => ( + <div> + <EuiPopoverTitle>{search}</EuiPopoverTitle> + {list} + </div> + )} + </EuiSelectable> + </EuiPopover> + </EuiFilterGroup> + ); +}; 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 0000000000000..18dc2e5aa9bd1 --- /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<HTMLInputElement>) => { + 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 ( + <EuiFlexGroup alignItems="center"> + <EuiFlexItem> + <EuiCheckbox + key={index.name} + id={index.name} + label={<EuiCode>{index.name}</EuiCode>} + onChange={changeIsSelected} + checked={isSelected} + disabled={isDisabled || index.validity === 'invalid'} + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + {index.validity === 'invalid' ? ( + <EuiToolTip content={formatValidationError(index.errors)}> + <EuiIcon type="alert" color="danger" /> + </EuiToolTip> + ) : index.validity === 'valid' ? ( + <IndexSetupDatasetFilter + availableDatasets={index.availableDatasets} + datasetFilter={index.datasetFilter} + isDisabled={!isSelected || isDisabled} + onChangeDatasetFilter={changeDatasetFilter} + /> + ) : null} + </EuiFlexItem> + </EuiFlexGroup> + ); +}; + +const formatValidationError = (errors: ValidationIndicesUIError[]): React.ReactNode => { + return errors.map(error => { + switch (error.error) { + case 'INDEX_NOT_FOUND': + return ( + <p key={`${error.error}-${error.index}`}> + <FormattedMessage + id="xpack.infra.analysisSetup.indicesSelectionIndexNotFound" + defaultMessage="No indices match the pattern {index}" + values={{ index: <EuiCode>{error.index}</EuiCode> }} + /> + </p> + ); + + case 'FIELD_NOT_FOUND': + return ( + <p key={`${error.error}-${error.index}-${error.field}`}> + <FormattedMessage + id="xpack.infra.analysisSetup.indicesSelectionNoTimestampField" + defaultMessage="At least one index matching {index} lacks a required field {field}." + values={{ + index: <EuiCode>{error.index}</EuiCode>, + field: <EuiCode>{error.field}</EuiCode>, + }} + /> + </p> + ); + + case 'FIELD_NOT_VALID': + return ( + <p key={`${error.error}-${error.index}-${error.field}`}> + <FormattedMessage + id="xpack.infra.analysisSetup.indicesSelectionTimestampNotValid" + defaultMessage="At least one index matching {index} has a field called {field} without the correct type." + values={{ + index: <EuiCode>{error.index}</EuiCode>, + field: <EuiCode>{error.field}</EuiCode>, + }} + /> + </p> + ); + + 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 4ec895dfed4bc..85aa7ce513248 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 8b733f66ef4a8..d69e544aeab18 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/components/logging/log_entry_flyout/log_entry_actions_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx index 206e9821190fb..a8597b7073c95 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx @@ -9,7 +9,7 @@ import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } f import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; import { useVisibilityState } from '../../../utils/use_visibility_state'; -import { getTraceUrl } from '../../../../../../legacy/plugins/apm/public/components/shared/Links/apm/ExternalLinks'; +import { getTraceUrl } from '../../../../../apm/public'; import { LogEntriesItem } from '../../../../common/http_api'; import { useLinkProps, LinkDescriptor } from '../../../hooks/use_link_props'; import { decodeOrThrow } from '../../../../common/runtime_types'; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx index 72d6aea5ecfc6..c713839a1bba8 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx @@ -22,7 +22,7 @@ import { } from './log_entry_column'; import { ASSUMED_SCROLLBAR_WIDTH } from './vertical_scroll_panel'; import { LogPositionState } from '../../../containers/logs/log_position'; -import { localizedDate } from '../../../utils/formatters/datetime'; +import { localizedDate } from '../../../../common/formatters/datetime'; export const LogColumnHeaders: React.FunctionComponent<{ columnConfigurations: LogColumnConfiguration[]; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_date_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_date_row.tsx index fbc450950b828..144caed744bab 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_date_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_date_row.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiTitle } from '@elastic/eui'; -import { localizedDate } from '../../../utils/formatters/datetime'; +import { localizedDate } from '../../../../common/formatters/datetime'; interface LogDateRowProps { timestamp: number; 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 b1265b389917e..7c8d63374924c 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<typeof setupMlModuleDatafeedOverridesRT>; -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 0000000000000..6c9d5e439d359 --- /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 99c5a3df7c9b1..cecfea28100ad 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 = <JobType extends string>({ 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 = <JobType extends string>({ ]); 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 dc9f25b492635..cc9ef73019844 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<JobType extends string> { moduleId: string; @@ -20,12 +24,20 @@ export interface ModuleDescriptor<JobType extends string> { setUpModule: ( start: number | undefined, end: number | undefined, + datasetFilter: DatasetFilter, sourceConfiguration: ModuleSourceConfiguration ) => Promise<SetupMlModuleResponsePayload>; cleanUpModule: (spaceId: string, sourceId: string) => Promise<DeleteJobsResponsePayload>; validateSetupIndices: ( - sourceConfiguration: ModuleSourceConfiguration + indices: string[], + timestampField: string ) => Promise<ValidationIndicesResponsePayload>; + validateSetupDatasets: ( + indices: string[], + timestampField: string, + startTime: number, + endTime: number + ) => Promise<ValidateLogEntryDatasetsResponsePayload>; } 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 0000000000000..d46e8bc2485f6 --- /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<JobType extends string> { + cleanUpAndSetUpModule: SetupHandler; + moduleDescriptor: ModuleDescriptor<JobType>; + setUpModule: SetupHandler; + sourceConfiguration: ModuleSourceConfiguration; +} + +const fourWeeksInMs = 86400000 * 7 * 4; + +export const useAnalysisSetupState = <JobType extends string>({ + cleanUpAndSetUpModule, + moduleDescriptor: { validateSetupDatasets, validateSetupIndices }, + setUpModule, + sourceConfiguration, +}: AnalysisSetupStateArguments<JobType>) => { + const [startTime, setStartTime] = useState<number | undefined>(Date.now() - fourWeeksInMs); + const [endTime, setEndTime] = useState<number | undefined>(undefined); + + const [validatedIndices, setValidatedIndices] = useState<AvailableIndex[]>( + 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<ValidationIndicesUIError[]>(() => { + if (isValidating) { + return []; + } + + if (validateIndicesRequest.state === 'rejected') { + return [{ error: 'NETWORK_ERROR' }]; + } + + if (selectedIndexNames.length === 0) { + return [{ error: 'TOO_FEW_SELECTED_INDICES' }]; + } + + return validatedIndices.reduce<ValidationIndicesUIError[]>((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 9f966ed3342e6..0000000000000 --- 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<JobType extends string> { - cleanUpAndSetUpModule: SetupHandler; - moduleDescriptor: ModuleDescriptor<JobType>; - setUpModule: SetupHandler; - sourceConfiguration: ModuleSourceConfiguration; -} - -const fourWeeksInMs = 86400000 * 7 * 4; - -export const useAnalysisSetupState = <JobType extends string>({ - cleanUpAndSetUpModule, - moduleDescriptor: { validateSetupIndices }, - setUpModule, - sourceConfiguration, -}: AnalysisSetupStateArguments<JobType>) => { - const [startTime, setStartTime] = useState<number | undefined>(Date.now() - fourWeeksInMs); - const [endTime, setEndTime] = useState<number | undefined>(undefined); - - const [validatedIndices, setValidatedIndices] = useState<ValidatedIndex[]>([]); - - 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<ValidationIndicesUIError[]>(() => { - if (isValidating) { - return []; - } - - if (validateIndicesRequest.state === 'rejected') { - return [{ error: 'NETWORK_ERROR' }]; - } - - if (selectedIndexNames.length === 0) { - return [{ error: 'TOO_FEW_SELECTED_INDICES' }]; - } - - return validatedIndices.reduce<ValidationIndicesUIError[]>((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 786cb485b38dd..e847302a6d367 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 2f1d15ffaf4d3..20e67a0a59c9f 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 848801ab3c7ce..4361e4bef827f 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 8332018fddf90..670988d680147 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/containers/source/use_source_via_http.ts b/x-pack/plugins/infra/public/containers/source/use_source_via_http.ts index bc6374a6538e3..aad54bd2222b7 100644 --- a/x-pack/plugins/infra/public/containers/source/use_source_via_http.ts +++ b/x-pack/plugins/infra/public/containers/source/use_source_via_http.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useCallback } from 'react'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -69,12 +69,15 @@ export const useSourceViaHttp = ({ })(); }, [makeRequest]); - const createDerivedIndexPattern = (indexType: 'logs' | 'metrics' | 'both' = type) => { - return { - fields: response?.source ? response.status.indexFields : [], - title: pickIndexPattern(response?.source, indexType), - }; - }; + const createDerivedIndexPattern = useCallback( + (indexType: 'logs' | 'metrics' | 'both' = type) => { + return { + fields: response?.source ? response.status.indexFields : [], + title: pickIndexPattern(response?.source, indexType), + }; + }, + [response, type] + ); const source = useMemo(() => { return response ? { ...response.source, status: response.status } : null; diff --git a/x-pack/plugins/infra/public/index.ts b/x-pack/plugins/infra/public/index.ts index 4465bde377c12..1dfdf827f203b 100644 --- a/x-pack/plugins/infra/public/index.ts +++ b/x-pack/plugins/infra/public/index.ts @@ -16,7 +16,7 @@ export const plugin: PluginInitializer< return new Plugin(context); }; -export { FORMATTERS } from './utils/formatters'; +export { FORMATTERS } from '../common/formatters'; export { InfraFormatterType } from './lib/lib'; export type InfraAppId = 'logs' | 'metrics'; diff --git a/x-pack/plugins/infra/public/lib/lib.ts b/x-pack/plugins/infra/public/lib/lib.ts index e4de0caf9bb8b..9043b4d9f6979 100644 --- a/x-pack/plugins/infra/public/lib/lib.ts +++ b/x-pack/plugins/infra/public/lib/lib.ts @@ -186,12 +186,6 @@ export enum InfraFormatterType { percent = 'percent', } -export enum InfraWaffleMapDataFormat { - bytesDecimal = 'bytesDecimal', - bitsDecimal = 'bitsDecimal', - abbreviatedNumber = 'abbreviatedNumber', -} - export interface InfraGroupByOptions { text: string; field: string; 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 be7547f2e74cb..45cdd28bd943b 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<LogEntryCategoriesJobType> = { moduleId, jobTypes: logEntryCategoriesJobTypes, @@ -111,5 +145,6 @@ export const logEntryCategoriesModule: ModuleDescriptor<LogEntryCategoriesJobTyp getModuleDefinition, setUpModule, cleanUpModule, + validateSetupDatasets, validateSetupIndices, }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_setup_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_setup_content.tsx index a7ff98923a427..7ae38234ae221 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_setup_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_setup_content.tsx @@ -55,7 +55,7 @@ export const LogEntryCategoriesSetupContent: 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/log_entry_rate/module_descriptor.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts index 52ba3101dbc38..dfd427138aaa6 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<LogEntryRateJobType> = { moduleId, jobTypes: logEntryRateJobTypes, @@ -106,5 +133,6 @@ export const logEntryRateModule: ModuleDescriptor<LogEntryRateJobType> = { 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 a02dbfa941588..e5c439808115d 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/log_entry_rate/sections/anomalies/chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx index 1a3a7d9e2b572..46e41f227c08d 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx @@ -15,6 +15,7 @@ import { LIGHT_THEME, DARK_THEME, RectAnnotation, + BrushEndListener, } from '@elastic/charts'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; @@ -53,8 +54,12 @@ export const AnomaliesChart: React.FunctionComponent<{ [dateFormat] ); - const handleBrushEnd = useCallback( - (startTime: number, endTime: number) => { + const handleBrushEnd = useCallback<BrushEndListener>( + ({ x }) => { + if (!x) { + return; + } + const [startTime, endTime] = x; setTimeRange({ endTime, startTime, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx index 3a244b6834c59..263c888ca6000 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx @@ -11,6 +11,7 @@ import { niceTimeFormatter, Settings, TooltipValue, + BrushEndListener, LIGHT_THEME, DARK_THEME, } from '@elastic/charts'; @@ -43,8 +44,12 @@ export const LogEntryRateBarChart: React.FunctionComponent<{ [dateFormat] ); - const handleBrushEnd = useCallback( - (startTime: number, endTime: number) => { + const handleBrushEnd = useCallback<BrushEndListener>( + ({ x }) => { + if (!x) { + return; + } + const [startTime, endTime] = x; setTimeRange({ endTime, startTime, 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 d2db5002f4aa2..1e053d8d4abc3 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 ( - <LogSourceProvider sourceId={sourceId}> + <LogSourceProvider sourceId={sourceId} fetch={services.http.fetch}> <LogAnalysisCapabilitiesProvider>{children}</LogAnalysisCapabilitiesProvider> </LogSourceProvider> ); diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index cc88dd9e0d0f8..dbf71665ea869 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -28,7 +28,9 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { WaffleOptionsProvider } from './inventory_view/hooks/use_waffle_options'; import { WaffleTimeProvider } from './inventory_view/hooks/use_waffle_time'; import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters'; -import { AlertDropdown } from '../../components/alerting/metrics/alert_dropdown'; + +import { InventoryAlertDropdown } from '../../components/alerting/inventory/alert_dropdown'; +import { MetricsAlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown'; export const InfrastructurePage = ({ match }: RouteComponentProps) => { const uiCapabilities = useKibana().services.application?.capabilities; @@ -96,7 +98,8 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { /> </EuiFlexItem> <EuiFlexItem grow={false}> - <Route path={'/explorer'} component={AlertDropdown} /> + <Route path={'/explorer'} component={MetricsAlertDropdown} /> + <Route path={'/inventory'} component={InventoryAlertDropdown} /> </EuiFlexItem> </EuiFlexGroup> </AppNavigation> diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/saved_views.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/saved_views.tsx index 356f0598e00d2..96271ea126046 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/saved_views.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/saved_views.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control'; -import { inventoryViewSavedObjectType } from '../../../../../common/saved_objects/inventory_view'; +import { inventoryViewSavedObjectName } from '../../../../../common/saved_objects/inventory_view'; import { useWaffleViewState } from '../hooks/use_waffle_view_state'; export const SavedViews = () => { @@ -15,7 +15,7 @@ export const SavedViews = () => { defaultViewState={defaultViewState} viewState={viewState} onViewChange={onViewChange} - viewType={inventoryViewSavedObjectType} + viewType={inventoryViewSavedObjectName} /> ); }; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx index 275635a33ec26..d576f08108649 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/node_context_menu.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo, useState } from 'react'; -import { AlertFlyout } from '../../../../../components/alerting/metrics/alert_flyout'; +import { AlertFlyout } from '../../../../../components/alerting/inventory/alert_flyout'; import { InfraWaffleMapNode, InfraWaffleMapOptions } from '../../../../../lib/lib'; import { getNodeDetailUrl, getNodeLogsUrl } from '../../../../link_to'; import { createUptimeLink } from '../../lib/create_uptime_link'; @@ -24,6 +24,8 @@ import { SectionSubtitle, SectionLinks, SectionLink, + withTheme, + EuiTheme, } from '../../../../../../../observability/public'; import { useLinkProps } from '../../../../../hooks/use_link_props'; @@ -37,157 +39,178 @@ interface Props { popoverPosition: EuiPopoverProps['anchorPosition']; } -export const NodeContextMenu: React.FC<Props> = ({ - options, - currentTime, - children, - node, - isPopoverOpen, - closePopover, - nodeType, - popoverPosition, -}) => { - const [flyoutVisible, setFlyoutVisible] = useState(false); - const inventoryModel = findInventoryModel(nodeType); - const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000; - const uiCapabilities = useKibana().services.application?.capabilities; - // Due to the changing nature of the fields between APM and this UI, - // We need to have some exceptions until 7.0 & ECS is finalized. Reference - // #26620 for the details for these fields. - // TODO: This is tech debt, remove it after 7.0 & ECS migration. - const apmField = nodeType === 'host' ? 'host.hostname' : inventoryModel.fields.id; +export const NodeContextMenu: React.FC<Props & { theme?: EuiTheme }> = withTheme( + ({ + options, + currentTime, + children, + node, + isPopoverOpen, + closePopover, + nodeType, + popoverPosition, + theme, + }) => { + const [flyoutVisible, setFlyoutVisible] = useState(false); + const inventoryModel = findInventoryModel(nodeType); + const nodeDetailFrom = currentTime - inventoryModel.metrics.defaultTimeRangeInSeconds * 1000; + const uiCapabilities = useKibana().services.application?.capabilities; + // Due to the changing nature of the fields between APM and this UI, + // We need to have some exceptions until 7.0 & ECS is finalized. Reference + // #26620 for the details for these fields. + // TODO: This is tech debt, remove it after 7.0 & ECS migration. + const apmField = nodeType === 'host' ? 'host.hostname' : inventoryModel.fields.id; - const showDetail = inventoryModel.crosslinkSupport.details; - const showLogsLink = - inventoryModel.crosslinkSupport.logs && node.id && uiCapabilities?.logs?.show; - const showAPMTraceLink = - inventoryModel.crosslinkSupport.apm && uiCapabilities?.apm && uiCapabilities?.apm.show; - const showUptimeLink = - inventoryModel.crosslinkSupport.uptime && (['pod', 'container'].includes(nodeType) || node.ip); + const showDetail = inventoryModel.crosslinkSupport.details; + const showLogsLink = + inventoryModel.crosslinkSupport.logs && node.id && uiCapabilities?.logs?.show; + const showAPMTraceLink = + inventoryModel.crosslinkSupport.apm && uiCapabilities?.apm && uiCapabilities?.apm.show; + const showUptimeLink = + inventoryModel.crosslinkSupport.uptime && + (['pod', 'container'].includes(nodeType) || node.ip); - const inventoryId = useMemo(() => { - if (nodeType === 'host') { - if (node.ip) { - return { label: <EuiCode>host.ip</EuiCode>, value: node.ip }; + const inventoryId = useMemo(() => { + if (nodeType === 'host') { + if (node.ip) { + return { label: <EuiCode>host.ip</EuiCode>, value: node.ip }; + } + } else { + if (options.fields) { + const { id } = findInventoryFields(nodeType, options.fields); + return { + label: <EuiCode>{id}</EuiCode>, + value: node.id, + }; + } } - } else { - if (options.fields) { - const { id } = findInventoryFields(nodeType, options.fields); - return { - label: <EuiCode>{id}</EuiCode>, - value: node.id, - }; - } - } - return { label: '', value: '' }; - }, [nodeType, node.ip, node.id, options.fields]); + return { label: '', value: '' }; + }, [nodeType, node.ip, node.id, options.fields]); + + const nodeLogsMenuItemLinkProps = useLinkProps({ + app: 'logs', + ...getNodeLogsUrl({ + nodeType, + nodeId: node.id, + time: currentTime, + }), + }); + const nodeDetailMenuItemLinkProps = useLinkProps({ + ...getNodeDetailUrl({ + nodeType, + nodeId: node.id, + from: nodeDetailFrom, + to: currentTime, + }), + }); + const apmTracesMenuItemLinkProps = useLinkProps({ + app: 'apm', + hash: 'traces', + search: { + kuery: `${apmField}:"${node.id}"`, + }, + }); + const uptimeMenuItemLinkProps = useLinkProps(createUptimeLink(options, nodeType, node)); - const nodeLogsMenuItemLinkProps = useLinkProps({ - app: 'logs', - ...getNodeLogsUrl({ - nodeType, - nodeId: node.id, - time: currentTime, - }), - }); - const nodeDetailMenuItemLinkProps = useLinkProps({ - ...getNodeDetailUrl({ - nodeType, - nodeId: node.id, - from: nodeDetailFrom, - to: currentTime, - }), - }); - const apmTracesMenuItemLinkProps = useLinkProps({ - app: 'apm', - hash: 'traces', - search: { - kuery: `${apmField}:"${node.id}"`, - }, - }); - const uptimeMenuItemLinkProps = useLinkProps(createUptimeLink(options, nodeType, node)); + const nodeLogsMenuItem: SectionLinkProps = { + label: i18n.translate('xpack.infra.nodeContextMenu.viewLogsName', { + defaultMessage: '{inventoryName} logs', + values: { inventoryName: inventoryModel.singularDisplayName }, + }), + ...nodeLogsMenuItemLinkProps, + 'data-test-subj': 'viewLogsContextMenuItem', + isDisabled: !showLogsLink, + }; - const nodeLogsMenuItem: SectionLinkProps = { - label: i18n.translate('xpack.infra.nodeContextMenu.viewLogsName', { - defaultMessage: '{inventoryName} logs', - values: { inventoryName: inventoryModel.singularDisplayName }, - }), - ...nodeLogsMenuItemLinkProps, - 'data-test-subj': 'viewLogsContextMenuItem', - isDisabled: !showLogsLink, - }; + const nodeDetailMenuItem: SectionLinkProps = { + label: i18n.translate('xpack.infra.nodeContextMenu.viewMetricsName', { + defaultMessage: '{inventoryName} metrics', + values: { inventoryName: inventoryModel.singularDisplayName }, + }), + ...nodeDetailMenuItemLinkProps, + isDisabled: !showDetail, + }; - const nodeDetailMenuItem: SectionLinkProps = { - label: i18n.translate('xpack.infra.nodeContextMenu.viewMetricsName', { - defaultMessage: '{inventoryName} metrics', - values: { inventoryName: inventoryModel.singularDisplayName }, - }), - ...nodeDetailMenuItemLinkProps, - isDisabled: !showDetail, - }; + const apmTracesMenuItem: SectionLinkProps = { + label: i18n.translate('xpack.infra.nodeContextMenu.viewAPMTraces', { + defaultMessage: '{inventoryName} APM traces', + values: { inventoryName: inventoryModel.singularDisplayName }, + }), + ...apmTracesMenuItemLinkProps, + 'data-test-subj': 'viewApmTracesContextMenuItem', + isDisabled: !showAPMTraceLink, + }; - const apmTracesMenuItem: SectionLinkProps = { - label: i18n.translate('xpack.infra.nodeContextMenu.viewAPMTraces', { - defaultMessage: '{inventoryName} APM traces', - values: { inventoryName: inventoryModel.singularDisplayName }, - }), - ...apmTracesMenuItemLinkProps, - 'data-test-subj': 'viewApmTracesContextMenuItem', - isDisabled: !showAPMTraceLink, - }; + const uptimeMenuItem: SectionLinkProps = { + label: i18n.translate('xpack.infra.nodeContextMenu.viewUptimeLink', { + defaultMessage: '{inventoryName} in Uptime', + values: { inventoryName: inventoryModel.singularDisplayName }, + }), + ...uptimeMenuItemLinkProps, + isDisabled: !showUptimeLink, + }; - const uptimeMenuItem: SectionLinkProps = { - label: i18n.translate('xpack.infra.nodeContextMenu.viewUptimeLink', { - defaultMessage: '{inventoryName} in Uptime', - values: { inventoryName: inventoryModel.singularDisplayName }, - }), - ...uptimeMenuItemLinkProps, - isDisabled: !showUptimeLink, - }; + const createAlertMenuItem: SectionLinkProps = { + label: i18n.translate('xpack.infra.nodeContextMenu.createAlertLink', { + defaultMessage: 'Create alert', + }), + style: { color: theme?.eui.euiLinkColor || '#006BB4', fontWeight: 500, padding: 0 }, + onClick: () => { + setFlyoutVisible(true); + }, + }; - return ( - <> - <ActionMenu - closePopover={closePopover} - id={`${node.pathId}-popover`} - isOpen={isPopoverOpen} - button={children!} - anchorPosition={popoverPosition} - > - <div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu"> - <Section> - <SectionTitle> - <FormattedMessage - id="xpack.infra.nodeContextMenu.title" - defaultMessage="{inventoryName} details" - values={{ inventoryName: inventoryModel.singularDisplayName }} - /> - </SectionTitle> - {inventoryId.label && ( - <SectionSubtitle> - <div style={{ wordBreak: 'break-all' }}> - <FormattedMessage - id="xpack.infra.nodeContextMenu.description" - defaultMessage="View details for {label} {value}" - values={{ label: inventoryId.label, value: inventoryId.value }} - /> - </div> - </SectionSubtitle> - )} - <SectionLinks> - <SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} /> - <SectionLink {...nodeDetailMenuItem} /> - <SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} /> - <SectionLink {...uptimeMenuItem} /> - </SectionLinks> - </Section> - </div> - </ActionMenu> - <AlertFlyout - options={{ filterQuery: `${nodeType}: ${node.id}` }} - setVisible={setFlyoutVisible} - visible={flyoutVisible} - /> - </> - ); -}; + return ( + <> + <ActionMenu + closePopover={closePopover} + id={`${node.pathId}-popover`} + isOpen={isPopoverOpen} + button={children!} + anchorPosition={popoverPosition} + > + <div style={{ maxWidth: 300 }} data-test-subj="nodeContextMenu"> + <Section> + <SectionTitle> + <FormattedMessage + id="xpack.infra.nodeContextMenu.title" + defaultMessage="{inventoryName} details" + values={{ inventoryName: inventoryModel.singularDisplayName }} + /> + </SectionTitle> + {inventoryId.label && ( + <SectionSubtitle> + <div style={{ wordBreak: 'break-all' }}> + <FormattedMessage + id="xpack.infra.nodeContextMenu.description" + defaultMessage="View details for {label} {value}" + values={{ label: inventoryId.label, value: inventoryId.value }} + /> + </div> + </SectionSubtitle> + )} + <SectionLinks> + <SectionLink data-test-subj="viewLogsContextMenuItem" {...nodeLogsMenuItem} /> + <SectionLink {...nodeDetailMenuItem} /> + <SectionLink data-test-subj="viewApmTracesContextMenuItem" {...apmTracesMenuItem} /> + <SectionLink {...uptimeMenuItem} /> + <SectionLink {...createAlertMenuItem} /> + </SectionLinks> + </Section> + </div> + </ActionMenu> + <AlertFlyout + filter={ + options.fields + ? `${findInventoryFields(nodeType, options.fields).id}: "${node.id}"` + : '' + } + options={options} + nodeType={nodeType} + setVisible={setFlyoutVisible} + visible={flyoutVisible} + /> + </> + ); + } +); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_inventory_metric_formatter.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_inventory_metric_formatter.ts index acd71e5137694..f8c7a10f12831 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_inventory_metric_formatter.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/create_inventory_metric_formatter.ts @@ -5,13 +5,13 @@ */ import { get } from 'lodash'; -import { createFormatter } from '../../../../utils/formatters'; import { InfraFormatterType } from '../../../../lib/lib'; import { SnapshotMetricInput, SnapshotCustomMetricInputRT, } from '../../../../../common/http_api/snapshot_api'; import { createFormatterForMetric } from '../../metrics_explorer/components/helpers/create_formatter_for_metric'; +import { createFormatter } from '../../../../../common/formatters'; interface MetricFormatter { formatter: InfraFormatterType; diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/chart_section_vis.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/chart_section_vis.tsx index 588a0d84918c6..7bf5dd6caae48 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/chart_section_vis.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/chart_section_vis.tsx @@ -6,7 +6,15 @@ import React, { useCallback, useMemo } from 'react'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; -import { Axis, Chart, niceTimeFormatter, Position, Settings, TooltipValue } from '@elastic/charts'; +import { + Axis, + Chart, + niceTimeFormatter, + Position, + Settings, + TooltipValue, + BrushEndListener, +} from '@elastic/charts'; import { EuiPageContentBody } from '@elastic/eui'; import { getChartTheme } from '../../metrics_explorer/components/helpers/get_chart_theme'; import { SeriesChart } from './series_chart'; @@ -45,8 +53,12 @@ export const ChartSectionVis = ({ () => (metric != null ? niceTimeFormatter(getMaxMinTimestamp(metric)) : undefined), [metric] ); - const handleTimeChange = useCallback( - (from: number, to: number) => { + const handleTimeChange = useCallback<BrushEndListener>( + ({ x }) => { + if (!x) { + return; + } + const [from, to] = x; if (onChangeRangeTime) { if (isLiveStreaming && stopLiveStreaming) { stopLiveStreaming(); diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/gauges_section_vis.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/gauges_section_vis.tsx index 0aab676b7d6c5..0f53ced80888b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/gauges_section_vis.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/gauges_section_vis.tsx @@ -17,7 +17,7 @@ import { get, last, max } from 'lodash'; import React, { ReactText } from 'react'; import { euiStyled } from '../../../../../../observability/public'; -import { createFormatter } from '../../../../utils/formatters'; +import { createFormatter } from '../../../../../common/formatters'; import { InventoryFormatterType } from '../../../../../common/inventory_models/types'; import { SeriesOverrides, VisSectionProps } from '../types'; import { getChartName } from './helpers'; diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/helpers.ts b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/helpers.ts index bb4ad32660952..0b8773db2dddf 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/helpers.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/helpers.ts @@ -7,7 +7,7 @@ import { ReactText } from 'react'; import Color from 'color'; import { get, first, last, min, max } from 'lodash'; -import { createFormatter } from '../../../../utils/formatters'; +import { createFormatter } from '../../../../../common/formatters'; import { InfraDataSeries } from '../../../../graphql/types'; import { InventoryVisTypeRT, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx index 0c0f7b33b3a4a..5a84d204b3b25 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/aggregation.tsx @@ -26,6 +26,9 @@ export const MetricsExplorerAggregationPicker = ({ options, onChange }: Props) = ['avg']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.avg', { defaultMessage: 'Average', }), + ['sum']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.sum', { + defaultMessage: 'Sum', + }), ['max']: i18n.translate('xpack.infra.metricsExplorer.aggregationLables.max', { defaultMessage: 'Max', }), diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx index 089e1abfc4c91..9bd53118cf0ec 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx @@ -7,7 +7,15 @@ import React, { useCallback, useMemo } from 'react'; import { EuiTitle, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { Axis, Chart, niceTimeFormatter, Position, Settings, TooltipValue } from '@elastic/charts'; +import { + Axis, + Chart, + niceTimeFormatter, + Position, + Settings, + TooltipValue, + BrushEndListener, +} from '@elastic/charts'; import { first, last } from 'lodash'; import moment from 'moment'; import { MetricsExplorerSeries } from '../../../../../common/http_api/metrics_explorer'; @@ -58,7 +66,11 @@ export const MetricsExplorerChart = ({ const isDarkMode = useUiSetting<boolean>('theme:darkMode'); const { metrics } = options; const [dateFormat] = useKibanaUiSetting('dateFormat'); - const handleTimeChange = (from: number, to: number) => { + const handleTimeChange: BrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [from, to] = x; onTimeChange(moment(from).toISOString(), moment(to).toISOString()); }; const dateFormatter = useMemo( diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx index 31086a21ca13f..e13c9dcc06984 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_context_menu.tsx @@ -14,7 +14,7 @@ import { } from '@elastic/eui'; import DateMath from '@elastic/datemath'; import { Capabilities } from 'src/core/public'; -import { AlertFlyout } from '../../../../components/alerting/metrics/alert_flyout'; +import { AlertFlyout } from '../../../../alerting/metric_threshold/components/alert_flyout'; import { MetricsExplorerSeries } from '../../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions, diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_formatter_for_metric.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_formatter_for_metric.ts index d07a6b45f02be..46bd7b006446a 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_formatter_for_metric.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_formatter_for_metric.ts @@ -5,7 +5,7 @@ */ import { MetricsExplorerMetric } from '../../../../../../common/http_api/metrics_explorer'; -import { createFormatter } from '../../../../../utils/formatters'; +import { createFormatter } from '../../../../../../common/formatters'; import { InfraFormatterType } from '../../../../../lib/lib'; import { metricToFormat } from './metric_to_format'; export const createFormatterForMetric = (metric?: MetricsExplorerMetric) => { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_metric_label.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_metric_label.ts index 1607302a6259a..6343f2848054b 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_metric_label.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/create_metric_label.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MetricsExplorerMetric } from '../../../../../../common/http_api/metrics_explorer'; +import { MetricsExplorerOptionsMetric } from '../../hooks/use_metrics_explorer_options'; -export const createMetricLabel = (metric: MetricsExplorerMetric) => { +export const createMetricLabel = (metric: MetricsExplorerOptionsMetric) => { + if (metric.label) { + return metric.label; + } return `${metric.aggregation}(${metric.field || ''})`; }; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx index e9826e1ff3955..04661bbc37702 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx @@ -14,6 +14,7 @@ import { esKuery, IIndexPattern } from '../../../../../../../../src/plugins/data interface Props { derivedIndexPattern: IIndexPattern; onSubmit: (query: string) => void; + onChange?: (query: string) => void; value?: string | null; placeholder?: string; } @@ -30,6 +31,7 @@ function validateQuery(query: string) { export const MetricsExplorerKueryBar = ({ derivedIndexPattern, onSubmit, + onChange, value, placeholder, }: Props) => { @@ -46,6 +48,9 @@ export const MetricsExplorerKueryBar = ({ const handleChange = (query: string) => { setValidation(validateQuery(query)); setDraftQuery(query); + if (onChange) { + onChange(query); + } }; const filteredDerivedIndexPattern = { diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx index ad7ce83539526..3b84fcbc34836 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/series_chart.tsx @@ -21,12 +21,15 @@ import { MetricsExplorerChartType, } from '../hooks/use_metrics_explorer_options'; +type NumberOrString = string | number; + interface Props { metric: MetricsExplorerOptionsMetric; - id: string | number; + id: NumberOrString | NumberOrString[]; series: MetricsExplorerSeries; type: MetricsExplorerChartType; stack: boolean; + opacity?: number; } export const MetricExplorerSeriesChart = (props: Props) => { @@ -36,13 +39,17 @@ export const MetricExplorerSeriesChart = (props: Props) => { return <MetricsExplorerAreaChart {...props} />; }; -export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack }: Props) => { +export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack, opacity }: Props) => { const color = (metric.color && colorTransformer(metric.color)) || colorTransformer(MetricsExplorerColor.color0); - const yAccessor = `metric_${id}`; - const chartId = `series-${series.id}-${yAccessor}`; + const yAccessors = Array.isArray(id) + ? id.map(i => `metric_${i}`).slice(id.length - 1, id.length) + : [`metric_${id}`]; + const y0Accessors = + Array.isArray(id) && id.length > 1 ? id.map(i => `metric_${i}`).slice(0, 1) : undefined; + const chartId = `series-${series.id}-${yAccessors.join('-')}`; const seriesAreaStyle: RecursivePartial<AreaSeriesStyle> = { line: { @@ -50,19 +57,21 @@ export const MetricsExplorerAreaChart = ({ metric, id, series, type, stack }: Pr visible: true, }, area: { - opacity: 0.5, + opacity: opacity || 0.5, visible: type === MetricsExplorerChartType.area, }, }; + return ( <AreaSeries - id={yAccessor} + id={chartId} key={chartId} name={createMetricLabel(metric)} xScaleType={ScaleType.Time} yScaleType={ScaleType.Linear} xAccessor="timestamp" - yAccessors={[yAccessor]} + yAccessors={yAccessors} + y0Accessors={y0Accessors} data={series.rows} stackAccessors={stack ? ['timestamp'] : void 0} areaSeriesStyle={seriesAreaStyle} diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx index 6913f67bad08a..76945eb528345 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/toolbar.tsx @@ -24,7 +24,7 @@ import { MetricsExplorerAggregationPicker } from './aggregation'; import { MetricsExplorerChartOptions as MetricsExplorerChartOptionsComponent } from './chart_options'; import { SavedViewsToolbarControls } from '../../../../components/saved_views/toolbar_control'; import { MetricExplorerViewState } from '../hooks/use_metric_explorer_state'; -import { metricsExplorerViewSavedObjectType } from '../../../../../common/saved_objects/metrics_explorer_view'; +import { metricsExplorerViewSavedObjectName } from '../../../../../common/saved_objects/metrics_explorer_view'; import { useKibanaUiSetting } from '../../../../utils/use_kibana_ui_setting'; import { mapKibanaQuickRangesToDatePickerRanges } from '../../../../utils/map_timepicker_quickranges_to_datepicker_ranges'; import { ToolbarPanel } from '../../../../components/toolbar_panel'; @@ -129,7 +129,7 @@ export const MetricsExplorerToolbar = ({ chartOptions, currentTimerange: timeRange, }} - viewType={metricsExplorerViewSavedObjectType} + viewType={metricsExplorerViewSavedObjectName} onViewChange={onViewStateChange} /> </EuiFlexItem> diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts index 93aacb586a5cd..414e204f3df50 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_data.ts @@ -7,6 +7,7 @@ import DateMath from '@elastic/datemath'; import { isEqual } from 'lodash'; import { useEffect, useState } from 'react'; +import { HttpHandler } from 'target/types/core/public/http'; import { IIndexPattern } from 'src/plugins/data/public'; import { SourceQuery } from '../../../../../common/graphql/types'; import { @@ -24,13 +25,15 @@ function isSameOptions(current: MetricsExplorerOptions, next: MetricsExplorerOpt export function useMetricsExplorerData( options: MetricsExplorerOptions, - source: SourceQuery.Query['source']['configuration'], + source: SourceQuery.Query['source']['configuration'] | undefined, derivedIndexPattern: IIndexPattern, timerange: MetricsExplorerTimeOptions, afterKey: string | null, - signal: any + signal: any, + fetch?: HttpHandler ) { - const fetch = useKibana().services.http?.fetch; + const kibana = useKibana(); + const fetchFn = fetch ? fetch : kibana.services.http?.fetch; const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState<boolean>(true); const [data, setData] = useState<MetricsExplorerResponse | null>(null); @@ -46,13 +49,17 @@ export function useMetricsExplorerData( if (!from || !to) { throw new Error('Unalble to parse timerange'); } - if (!fetch) { + if (!fetchFn) { throw new Error('HTTP service is unavailable'); } + if (!source) { + throw new Error('Source is unavailable'); + } const response = decodeOrThrow(metricsExplorerResponseRT)( - await fetch('/api/infra/metrics_explorer', { + await fetchFn('/api/infra/metrics_explorer', { method: 'POST', body: JSON.stringify({ + forceInterval: options.forceInterval, metrics: options.aggregation === 'count' ? [{ aggregation: 'count' }] diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts index 9d124a6af8012..1b3e809fde61f 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options.ts @@ -40,6 +40,7 @@ export interface MetricsExplorerOptions { groupBy?: string; filterQuery?: string; aggregation: MetricsExplorerAggregation; + forceInterval?: boolean; } export interface MetricsExplorerTimeOptions { diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 40366b2a54f24..d61ef7fc4a631 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -21,8 +21,9 @@ import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/p import { DataEnhancedSetup, DataEnhancedStart } from '../../data_enhanced/public'; import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public'; -import { getAlertType as getMetricsAlertType } from './components/alerting/metrics/metric_threshold_alert_type'; import { getAlertType as getLogsAlertType } from './components/alerting/logs/log_threshold_alert_type'; +import { getInventoryMetricAlertType } from './components/alerting/inventory/metric_inventory_threshold_alert_type'; +import { createMetricThresholdAlertType } from './alerting/metric_threshold'; export type ClientSetup = void; export type ClientStart = void; @@ -53,8 +54,9 @@ export class Plugin setup(core: CoreSetup, pluginsSetup: ClientPluginsSetup) { registerFeatures(pluginsSetup.home); - pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getMetricsAlertType()); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getInventoryMetricAlertType()); pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(getLogsAlertType()); + pluginsSetup.triggers_actions_ui.alertTypeRegistry.register(createMetricThresholdAlertType()); core.application.register({ id: 'logs', diff --git a/x-pack/plugins/infra/public/utils/formatters/index.ts b/x-pack/plugins/infra/public/utils/formatters/index.ts deleted file mode 100644 index 3c60dba747825..0000000000000 --- a/x-pack/plugins/infra/public/utils/formatters/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Mustache from 'mustache'; -import { InfraWaffleMapDataFormat } from '../../lib/lib'; -import { createBytesFormatter } from './bytes'; -import { formatNumber } from './number'; -import { formatPercent } from './percent'; -import { InventoryFormatterType } from '../../../common/inventory_models/types'; -import { formatHighPercision } from './high_precision'; - -export const FORMATTERS = { - number: formatNumber, - // Because the implimentation for formatting large numbers is the same as formatting - // bytes we are re-using the same code, we just format the number using the abbreviated number format. - abbreviatedNumber: createBytesFormatter(InfraWaffleMapDataFormat.abbreviatedNumber), - // bytes in bytes formatted string out - bytes: createBytesFormatter(InfraWaffleMapDataFormat.bytesDecimal), - // bytes in bits formatted string out - bits: createBytesFormatter(InfraWaffleMapDataFormat.bitsDecimal), - percent: formatPercent, - highPercision: formatHighPercision, -}; - -export const createFormatter = (format: InventoryFormatterType, template: string = '{{value}}') => ( - val: string | number -) => { - if (val == null) { - return ''; - } - const fmtFn = FORMATTERS[format]; - const value = fmtFn(Number(val)); - return Mustache.render(template, { value }); -}; diff --git a/x-pack/plugins/infra/server/graphql/sources/resolvers.ts b/x-pack/plugins/infra/server/graphql/sources/resolvers.ts index f880eca933241..cffab4ba4f6f0 100644 --- a/x-pack/plugins/infra/server/graphql/sources/resolvers.ts +++ b/x-pack/plugins/infra/server/graphql/sources/resolvers.ts @@ -101,7 +101,9 @@ export const createSourcesResolvers = ( return requestedSourceConfiguration; }, async allSources(root, args, { req }) { - const sourceConfigurations = await libs.sources.getAllSourceConfigurations(req); + const sourceConfigurations = await libs.sources.getAllSourceConfigurations( + req.core.savedObjects.client + ); return sourceConfigurations; }, @@ -131,7 +133,7 @@ export const createSourcesResolvers = ( Mutation: { async createSource(root, args, { req }) { const sourceConfiguration = await libs.sources.createSourceConfiguration( - req, + req.core.savedObjects.client, args.id, compactObject({ ...args.sourceProperties, @@ -147,7 +149,7 @@ export const createSourcesResolvers = ( }; }, async deleteSource(root, args, { req }) { - await libs.sources.deleteSourceConfiguration(req, args.id); + await libs.sources.deleteSourceConfiguration(req.core.savedObjects.client, args.id); return { id: args.id, @@ -155,7 +157,7 @@ export const createSourcesResolvers = ( }, async updateSource(root, args, { req }) { const updatedSourceConfiguration = await libs.sources.updateSourceConfiguration( - req, + req.core.savedObjects.client, args.id, compactObject({ ...args.sourceProperties, diff --git a/x-pack/plugins/infra/server/index.ts b/x-pack/plugins/infra/server/index.ts index 6cb04897af3f5..f0d417c5c311a 100644 --- a/x-pack/plugins/infra/server/index.ts +++ b/x-pack/plugins/infra/server/index.ts @@ -6,9 +6,8 @@ import { PluginInitializerContext } from 'src/core/server'; import { config, InfraConfig, InfraServerPlugin, InfraPluginSetup } from './plugin'; -import { savedObjectMappings } from './saved_objects'; -export { config, InfraConfig, savedObjectMappings, InfraPluginSetup }; +export { config, InfraConfig, InfraPluginSetup }; export function plugin(context: PluginInitializerContext) { return new InfraServerPlugin(context); diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 4ed30380dc164..06135c6532d77 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/adapters/framework/adapter_types.ts b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts index 038fd457fb6c7..4bbbf8dcdee03 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/adapter_types.ts @@ -11,7 +11,7 @@ import { RouteMethod, RouteConfig } from '../../../../../../../src/core/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../plugins/features/server'; import { SpacesPluginSetup } from '../../../../../../plugins/spaces/server'; import { VisTypeTimeseriesSetup } from '../../../../../../../src/plugins/vis_type_timeseries/server'; -import { APMPluginContract } from '../../../../../../plugins/apm/server'; +import { APMPluginSetup } from '../../../../../../plugins/apm/server'; import { HomeServerPluginSetup } from '../../../../../../../src/plugins/home/server'; import { PluginSetupContract as AlertingPluginContract } from '../../../../../../plugins/alerting/server'; @@ -22,7 +22,7 @@ export interface InfraServerPluginDeps { usageCollection: UsageCollectionSetup; visTypeTimeseries: VisTypeTimeseriesSetup; features: FeaturesPluginSetup; - apm: APMPluginContract; + apm: APMPluginSetup; alerting: AlertingPluginContract; } diff --git a/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts index 5a5f9d0f8f529..62f324e01f8d9 100644 --- a/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/metrics/kibana_metrics_adapter.ts @@ -18,6 +18,7 @@ import { InventoryMetricRT, } from '../../../../common/inventory_models/types'; import { calculateMetricInterval } from '../../../utils/calculate_metric_interval'; +import { CallWithRequestParams, InfraDatabaseSearchResponse } from '../framework'; export class KibanaMetricsAdapter implements InfraMetricsAdapter { private framework: KibanaFramework; @@ -120,9 +121,14 @@ export class KibanaMetricsAdapter implements InfraMetricsAdapter { indexPattern, options.timerange.interval ); + + const client = <Hit = {}, Aggregation = undefined>( + opts: CallWithRequestParams + ): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> => + this.framework.callWithRequest(requestContext, 'search', opts); + const calculatedInterval = await calculateMetricInterval( - this.framework, - requestContext, + client, { indexPattern: `${options.sourceConfiguration.logAlias},${options.sourceConfiguration.metricAlias}`, timestampField: options.sourceConfiguration.fields.timestamp, diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts new file mode 100644 index 0000000000000..cc8a35f6e47a1 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -0,0 +1,214 @@ +/* + * 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 { mapValues, last, get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { + InfraDatabaseSearchResponse, + CallWithRequestParams, +} from '../../adapters/framework/adapter_types'; +import { Comparator, AlertStates, InventoryMetricConditions } from './types'; +import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server'; +import { InfraSnapshot } from '../../snapshot'; +import { parseFilterQuery } from '../../../utils/serialized_query'; +import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; +import { InfraSourceConfiguration } from '../../sources'; +import { InfraBackendLibs } from '../../infra_types'; +import { METRIC_FORMATTERS } from '../../../../common/formatters/snapshot_metric_formats'; +import { createFormatter } from '../../../../common/formatters'; + +interface InventoryMetricThresholdParams { + criteria: InventoryMetricConditions[]; + groupBy: string | undefined; + filterQuery: string | undefined; + nodeType: InventoryItemType; + sourceId?: string; +} + +export const createInventoryMetricThresholdExecutor = ( + libs: InfraBackendLibs, + alertId: string +) => async ({ services, params }: AlertExecutorOptions) => { + const { criteria, filterQuery, sourceId, nodeType } = params as InventoryMetricThresholdParams; + + const source = await libs.sources.getSourceConfiguration( + services.savedObjectsClient, + sourceId || 'default' + ); + + const results = await Promise.all( + criteria.map(c => evaluateCondtion(c, nodeType, source.configuration, services, filterQuery)) + ); + + const invenotryItems = Object.keys(results[0]); + for (const item of invenotryItems) { + const alertInstance = services.alertInstanceFactory(`${alertId}-${item}`); + // AND logic; all criteria must be across the threshold + const shouldAlertFire = results.every(result => result[item].shouldFire); + + // AND logic; because we need to evaluate all criteria, if one of them reports no data then the + // whole alert is in a No Data/Error state + const isNoData = results.some(result => result[item].isNoData); + const isError = results.some(result => result[item].isError); + + if (shouldAlertFire) { + alertInstance.scheduleActions(FIRED_ACTIONS.id, { + group: item, + item, + valueOf: mapToConditionsLookup(results, result => + formatMetric(result[item].metric, result[item].currentValue) + ), + thresholdOf: mapToConditionsLookup(criteria, c => c.threshold), + metricOf: mapToConditionsLookup(criteria, c => c.metric), + }); + } + + alertInstance.replaceState({ + alertState: isError + ? AlertStates.ERROR + : isNoData + ? AlertStates.NO_DATA + : shouldAlertFire + ? AlertStates.ALERT + : AlertStates.OK, + }); + } +}; + +interface ConditionResult { + shouldFire: boolean; + currentValue?: number | null; + isNoData: boolean; + isError: boolean; +} + +const evaluateCondtion = async ( + condition: InventoryMetricConditions, + nodeType: InventoryItemType, + sourceConfiguration: InfraSourceConfiguration, + services: AlertServices, + filterQuery?: string +): Promise<Record<string, ConditionResult>> => { + const { comparator, metric } = condition; + let { threshold } = condition; + + const currentValues = await getData( + services, + nodeType, + metric, + { + to: Date.now(), + from: moment() + .subtract(condition.timeSize, condition.timeUnit) + .toDate() + .getTime(), + interval: condition.timeUnit, + }, + sourceConfiguration, + filterQuery + ); + + threshold = threshold.map(n => convertMetricValue(metric, n)); + + const comparisonFunction = comparatorMap[comparator]; + + return mapValues(currentValues, value => ({ + shouldFire: value !== undefined && value !== null && comparisonFunction(value, threshold), + metric, + currentValue: value, + isNoData: value === null, + isError: value === undefined, + })); +}; + +const getData = async ( + services: AlertServices, + nodeType: InventoryItemType, + metric: SnapshotMetricType, + timerange: InfraTimerangeInput, + sourceConfiguration: InfraSourceConfiguration, + filterQuery?: string +) => { + const snapshot = new InfraSnapshot(); + const esClient = <Hit = {}, Aggregation = undefined>( + options: CallWithRequestParams + ): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> => + services.callCluster('search', options); + + const options = { + filterQuery: parseFilterQuery(filterQuery), + nodeType, + groupBy: [], + sourceConfiguration, + metric: { type: metric }, + timerange, + }; + + const { nodes } = await snapshot.getNodes(esClient, options); + + return nodes.reduce((acc, n) => { + const nodePathItem = last(n.path); + acc[nodePathItem.label] = n.metric && n.metric.value; + return acc; + }, {} as Record<string, number | undefined | null>); +}; + +const comparatorMap = { + [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => + value >= Math.min(a, b) && value <= Math.max(a, b), + // `threshold` is always an array of numbers in case the BETWEEN comparator is + // used; all other compartors will just destructure the first value in the array + [Comparator.GT]: (a: number, [b]: number[]) => a > b, + [Comparator.LT]: (a: number, [b]: number[]) => a < b, + [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b, + [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, + [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, +}; + +const mapToConditionsLookup = ( + list: any[], + mapFn: (value: any, index: number, array: any[]) => unknown +) => + list + .map(mapFn) + .reduce( + (result: Record<string, any>, value, i) => ({ ...result, [`condition${i}`]: value }), + {} + ); + +export const FIRED_ACTIONS = { + id: 'metrics.invenotry_threshold.fired', + name: i18n.translate('xpack.infra.metrics.alerting.inventory.threshold.fired', { + defaultMessage: 'Fired', + }), +}; + +// Some metrics in the UI are in a different unit that what we store in ES. +const convertMetricValue = (metric: SnapshotMetricType, value: number) => { + if (converters[metric]) { + return converters[metric](value); + } else { + return value; + } +}; +const converters: Record<string, (n: number) => number> = { + cpu: n => Number(n) / 100, + memory: n => Number(n) / 100, +}; + +const formatMetric = (metric: SnapshotMetricType, value: number) => { + // if (SnapshotCustomMetricInputRT.is(metric)) { + // const formatter = createFormatterForMetric(metric); + // return formatter(val); + // } + const metricFormatter = get(METRIC_FORMATTERS, metric, METRIC_FORMATTERS.count); + if (value == null) { + return ''; + } + const formatter = createFormatter(metricFormatter.formatter, metricFormatter.template); + return formatter(value); +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts new file mode 100644 index 0000000000000..3b6a1b5557bc6 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { curry } from 'lodash'; +import uuid from 'uuid'; +import { + createInventoryMetricThresholdExecutor, + FIRED_ACTIONS, +} from './inventory_metric_threshold_executor'; +import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from './types'; +import { InfraBackendLibs } from '../../infra_types'; + +const condition = schema.object({ + threshold: schema.arrayOf(schema.number()), + comparator: schema.oneOf([ + schema.literal('>'), + schema.literal('<'), + schema.literal('>='), + schema.literal('<='), + schema.literal('between'), + schema.literal('outside'), + ]), + timeUnit: schema.string(), + timeSize: schema.number(), + metric: schema.string(), +}); + +export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs) => ({ + id: METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + name: 'Inventory', + validate: { + params: schema.object( + { + criteria: schema.arrayOf(condition), + nodeType: schema.string(), + filterQuery: schema.maybe(schema.string()), + sourceId: schema.string(), + }, + { unknowns: 'allow' } + ), + }, + defaultActionGroupId: FIRED_ACTIONS.id, + actionGroups: [FIRED_ACTIONS], + executor: curry(createInventoryMetricThresholdExecutor)(libs, uuid.v4()), + actionVariables: { + context: [ + { + name: 'group', + description: i18n.translate( + 'xpack.infra.metrics.alerting.threshold.alerting.groupActionVariableDescription', + { + defaultMessage: 'Name of the group reporting data', + } + ), + }, + { + name: 'valueOf', + description: i18n.translate( + 'xpack.infra.metrics.alerting.threshold.alerting.valueOfActionVariableDescription', + { + defaultMessage: + 'Record of the current value of the watched metric; grouped by condition, i.e valueOf.condition0, valueOf.condition1, etc.', + } + ), + }, + { + name: 'thresholdOf', + description: i18n.translate( + 'xpack.infra.metrics.alerting.threshold.alerting.thresholdOfActionVariableDescription', + { + defaultMessage: + 'Record of the alerting threshold; grouped by condition, i.e thresholdOf.condition0, thresholdOf.condition1, etc.', + } + ), + }, + { + name: 'metricOf', + description: i18n.translate( + 'xpack.infra.metrics.alerting.threshold.alerting.metricOfActionVariableDescription', + { + defaultMessage: + 'Record of the watched metric; grouped by condition, i.e metricOf.condition0, metricOf.condition1, etc.', + } + ), + }, + ], + }, +}); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts new file mode 100644 index 0000000000000..73ee1ab6b7615 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SnapshotMetricType } from '../../../../common/inventory_models/types'; + +export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; + +export enum Comparator { + GT = '>', + LT = '<', + GT_OR_EQ = '>=', + LT_OR_EQ = '<=', + BETWEEN = 'between', + OUTSIDE_RANGE = 'outside', +} + +export enum AlertStates { + OK, + ALERT, + NO_DATA, + ERROR, +} + +export type TimeUnit = 's' | 'm' | 'h' | 'd'; + +export interface InventoryMetricConditions { + metric: SnapshotMetricType; + timeSize: number; + timeUnit: TimeUnit; + sourceId?: string; + threshold: number[]; + comparator: Comparator; +} diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/messages.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/messages.ts new file mode 100644 index 0000000000000..4878574e39d16 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/messages.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { Comparator, AlertStates } from './types'; + +export const DOCUMENT_COUNT_I18N = i18n.translate( + 'xpack.infra.metrics.alerting.threshold.documentCount', + { + defaultMessage: 'Document count', + } +); + +export const stateToAlertMessage = { + [AlertStates.ALERT]: i18n.translate('xpack.infra.metrics.alerting.threshold.alertState', { + defaultMessage: 'ALERT', + }), + [AlertStates.NO_DATA]: i18n.translate('xpack.infra.metrics.alerting.threshold.noDataState', { + defaultMessage: 'NO DATA', + }), + [AlertStates.ERROR]: i18n.translate('xpack.infra.metrics.alerting.threshold.errorState', { + defaultMessage: 'ERROR', + }), + // TODO: Implement recovered message state + [AlertStates.OK]: i18n.translate('xpack.infra.metrics.alerting.threshold.okState', { + defaultMessage: 'OK [Recovered]', + }), +}; + +const comparatorToI18n = (comparator: Comparator, threshold: number[], currentValue: number) => { + const gtText = i18n.translate('xpack.infra.metrics.alerting.threshold.gtComparator', { + defaultMessage: 'greater than', + }); + const ltText = i18n.translate('xpack.infra.metrics.alerting.threshold.ltComparator', { + defaultMessage: 'less than', + }); + const eqText = i18n.translate('xpack.infra.metrics.alerting.threshold.eqComparator', { + defaultMessage: 'equal to', + }); + + switch (comparator) { + case Comparator.BETWEEN: + return i18n.translate('xpack.infra.metrics.alerting.threshold.betweenComparator', { + defaultMessage: 'between', + }); + case Comparator.OUTSIDE_RANGE: + return i18n.translate('xpack.infra.metrics.alerting.threshold.outsideRangeComparator', { + defaultMessage: 'not between', + }); + case Comparator.GT: + return gtText; + case Comparator.LT: + return ltText; + case Comparator.GT_OR_EQ: + case Comparator.LT_OR_EQ: + if (threshold[0] === currentValue) return eqText; + else if (threshold[0] < currentValue) return ltText; + return gtText; + } +}; + +const thresholdToI18n = ([a, b]: number[]) => { + if (typeof b === 'undefined') return a; + return i18n.translate('xpack.infra.metrics.alerting.threshold.thresholdRange', { + defaultMessage: '{a} and {b}', + values: { a, b }, + }); +}; + +export const buildFiredAlertReason: (alertResult: { + metric: string; + comparator: Comparator; + threshold: number[]; + currentValue: number; +}) => string = ({ metric, comparator, threshold, currentValue }) => + i18n.translate('xpack.infra.metrics.alerting.threshold.firedAlertReason', { + defaultMessage: + '{metric} is {comparator} a threshold of {threshold} (current value is {currentValue})', + values: { + metric, + comparator: comparatorToI18n(comparator, threshold, currentValue), + threshold: thresholdToI18n(threshold), + currentValue, + }, + }); + +export const buildNoDataAlertReason: (alertResult: { + metric: string; + timeSize: number; + timeUnit: string; +}) => string = ({ metric, timeSize, timeUnit }) => + i18n.translate('xpack.infra.metrics.alerting.threshold.noDataAlertReason', { + defaultMessage: '{metric} has reported no data over the past {interval}', + values: { + metric, + interval: `${timeSize}${timeUnit}`, + }, + }); + +export const buildErrorAlertReason = (metric: string) => + i18n.translate('xpack.infra.metrics.alerting.threshold.errorAlertReason', { + defaultMessage: 'Elasticsearch failed when attempting to query data for {metric}', + values: { + metric, + }, + }); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 24b6ba2ec378b..2531e939792af 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; import { Comparator, AlertStates } from './types'; import * as mocks from './test_mocks'; @@ -13,79 +12,14 @@ import { AlertServicesMock, AlertInstanceMock, } from '../../../../../alerting/server/mocks'; - -const executor = createMetricThresholdExecutor('test') as (opts: { - params: AlertExecutorOptions['params']; - services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; -}) => Promise<void>; - -const services: AlertServicesMock = alertsMock.createAlertServices(); -services.callCluster.mockImplementation(async (_: string, { body, index }: any) => { - if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse; - const metric = body.query.bool.filter[1]?.exists.field; - if (body.aggs.groupings) { - if (body.aggs.groupings.composite.after) { - return mocks.compositeEndResponse; - } - if (metric === 'test.metric.2') { - return mocks.alternateCompositeResponse; - } - return mocks.basicCompositeResponse; - } - if (metric === 'test.metric.2') { - return mocks.alternateMetricResponse; - } - return mocks.basicMetricResponse; -}); -services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => { - if (sourceId === 'alternate') - return { - id: 'alternate', - attributes: { metricAlias: 'alternatebeat-*' }, - type, - references: [], - }; - return { id: 'default', attributes: { metricAlias: 'metricbeat-*' }, type, references: [] }; -}); +import { InfraSources } from '../../sources'; interface AlertTestInstance { instance: AlertInstanceMock; actionQueue: any[]; state: any; } -const alertInstances = new Map<string, AlertTestInstance>(); -services.alertInstanceFactory.mockImplementation((instanceID: string) => { - const alertInstance: AlertTestInstance = { - instance: alertsMock.createAlertInstanceFactory(), - actionQueue: [], - state: {}, - }; - alertInstances.set(instanceID, alertInstance); - alertInstance.instance.replaceState.mockImplementation((newState: any) => { - alertInstance.state = newState; - return alertInstance.instance; - }); - alertInstance.instance.scheduleActions.mockImplementation((id: string, action: any) => { - alertInstance.actionQueue.push({ id, action }); - return alertInstance.instance; - }); - return alertInstance.instance; -}); - -function mostRecentAction(id: string) { - return alertInstances.get(id)!.actionQueue.pop(); -} -function getState(id: string) { - return alertInstances.get(id)!.state; -} - -const baseCriterion = { - aggType: 'avg', - metric: 'test.metric.1', - timeSize: 1, - timeUnit: 'm', -}; describe('The metric threshold alert type', () => { describe('querying the entire infrastructure', () => { const instanceID = 'test-*'; @@ -161,17 +95,9 @@ describe('The metric threshold alert type', () => { await execute(Comparator.GT, [0.75]); const { action } = mostRecentAction(instanceID); expect(action.group).toBe('*'); - expect(action.valueOf.condition0).toBe(1); - expect(action.thresholdOf.condition0).toStrictEqual([0.75]); - expect(action.metricOf.condition0).toBe('test.metric.1'); - }); - test('fetches the index pattern dynamically', async () => { - await execute(Comparator.LT, [17], 'alternate'); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); - await execute(Comparator.LT, [1.5], 'alternate'); - expect(mostRecentAction(instanceID)).toBe(undefined); - expect(getState(instanceID).alertState).toBe(AlertStates.OK); + expect(action.reason).toContain('current value is 1'); + expect(action.reason).toContain('threshold of 0.75'); + expect(action.reason).toContain('test.metric.1'); }); }); @@ -271,12 +197,14 @@ describe('The metric threshold alert type', () => { const instanceID = 'test-*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); const { action } = mostRecentAction(instanceID); - expect(action.valueOf.condition0).toBe(1); - expect(action.valueOf.condition1).toBe(3.5); - expect(action.thresholdOf.condition0).toStrictEqual([1.0]); - expect(action.thresholdOf.condition1).toStrictEqual([3.0]); - expect(action.metricOf.condition0).toBe('test.metric.1'); - expect(action.metricOf.condition1).toBe('test.metric.2'); + const reasons = action.reason.split('\n'); + expect(reasons.length).toBe(2); + expect(reasons[0]).toContain('test.metric.1'); + expect(reasons[1]).toContain('test.metric.2'); + expect(reasons[0]).toContain('current value is 1'); + expect(reasons[1]).toContain('current value is 3.5'); + expect(reasons[0]).toContain('threshold of 1'); + expect(reasons[1]).toContain('threshold of 3'); }); }); describe('querying with the count aggregator', () => { @@ -305,4 +233,146 @@ describe('The metric threshold alert type', () => { expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); }); + describe("querying a metric that hasn't reported data", () => { + const instanceID = 'test-*'; + const execute = (alertOnNoData: boolean) => + executor({ + services, + params: { + criteria: [ + { + ...baseCriterion, + comparator: Comparator.GT, + threshold: 1, + metric: 'test.metric.3', + }, + ], + alertOnNoData, + }, + }); + test('sends a No Data alert when configured to do so', async () => { + await execute(true); + expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(getState(instanceID).alertState).toBe(AlertStates.NO_DATA); + }); + test('does not send a No Data alert when not configured to do so', async () => { + await execute(false); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(getState(instanceID).alertState).toBe(AlertStates.NO_DATA); + }); + }); +}); + +const createMockStaticConfiguration = (sources: any) => ({ + enabled: true, + query: { + partitionSize: 1, + partitionFactor: 1, + }, + sources, +}); + +const mockLibs: any = { + sources: new InfraSources({ + config: createMockStaticConfiguration({}), + }), + configuration: createMockStaticConfiguration({}), +}; + +const executor = createMetricThresholdExecutor(mockLibs, 'test') as (opts: { + params: AlertExecutorOptions['params']; + services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; +}) => Promise<void>; + +const services: AlertServicesMock = alertsMock.createAlertServices(); +services.callCluster.mockImplementation(async (_: string, { body, index }: any) => { + if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse; + const metric = body.query.bool.filter[1]?.exists.field; + if (body.aggs.groupings) { + if (body.aggs.groupings.composite.after) { + return mocks.compositeEndResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateCompositeResponse; + } + return mocks.basicCompositeResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateMetricResponse; + } + return mocks.basicMetricResponse; +}); +services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => { + if (sourceId === 'alternate') + return { + id: 'alternate', + attributes: { metricAlias: 'alternatebeat-*' }, + type, + references: [], + }; + return { id: 'default', attributes: { metricAlias: 'metricbeat-*' }, type, references: [] }; +}); + +services.callCluster.mockImplementation(async (_: string, { body, index }: any) => { + if (index === 'alternatebeat-*') return mocks.changedSourceIdResponse; + const metric = body.query.bool.filter[1]?.exists.field; + if (body.aggs.groupings) { + if (body.aggs.groupings.composite.after) { + return mocks.compositeEndResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateCompositeResponse; + } + return mocks.basicCompositeResponse; + } + if (metric === 'test.metric.2') { + return mocks.alternateMetricResponse; + } else if (metric === 'test.metric.3') { + return mocks.emptyMetricResponse; + } + return mocks.basicMetricResponse; +}); +services.savedObjectsClient.get.mockImplementation(async (type: string, sourceId: string) => { + if (sourceId === 'alternate') + return { + id: 'alternate', + attributes: { metricAlias: 'alternatebeat-*' }, + type, + references: [], + }; + return { id: 'default', attributes: { metricAlias: 'metricbeat-*' }, type, references: [] }; +}); + +const alertInstances = new Map<string, AlertTestInstance>(); +services.alertInstanceFactory.mockImplementation((instanceID: string) => { + const alertInstance: AlertTestInstance = { + instance: alertsMock.createAlertInstanceFactory(), + actionQueue: [], + state: {}, + }; + alertInstances.set(instanceID, alertInstance); + alertInstance.instance.replaceState.mockImplementation((newState: any) => { + alertInstance.state = newState; + return alertInstance.instance; + }); + alertInstance.instance.scheduleActions.mockImplementation((id: string, action: any) => { + alertInstance.actionQueue.push({ id, action }); + return alertInstance.instance; + }); + return alertInstance.instance; }); + +function mostRecentAction(id: string) { + return alertInstances.get(id)!.actionQueue.pop(); +} + +function getState(id: string) { + return alertInstances.get(id)!.state; +} + +const baseCriterion = { + aggType: 'avg', + metric: 'test.metric.1', + timeSize: 1, + timeUnit: 'm', +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index cf691f73bdc2c..5c34a058577a1 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -5,19 +5,24 @@ */ import { mapValues } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { convertSavedObjectToSavedSourceConfiguration } from '../../sources/sources'; -import { infraSourceConfigurationSavedObjectType } from '../../sources/saved_object_mappings'; import { InfraDatabaseSearchResponse } from '../../adapters/framework/adapter_types'; import { createAfterKeyHandler } from '../../../utils/create_afterkey_handler'; import { getAllCompositeData } from '../../../utils/get_all_composite_data'; import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; import { MetricExpressionParams, Comparator, Aggregators, AlertStates } from './types'; +import { + buildErrorAlertReason, + buildFiredAlertReason, + buildNoDataAlertReason, + DOCUMENT_COUNT_I18N, + stateToAlertMessage, +} from './messages'; import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { getDateHistogramOffset } from '../../snapshot/query_helpers'; +import { InfraBackendLibs } from '../../infra_types'; const TOTAL_BUCKETS = 5; -const DEFAULT_INDEX_PATTERN = 'metricbeat-*'; interface Aggregation { aggregatedIntervals: { @@ -69,6 +74,7 @@ const getParsedFilterQuery: ( export const getElasticsearchMetricQuery = ( { metric, aggType, timeUnit, timeSize }: MetricExpressionParams, + timefield: string, groupBy?: string, filterQuery?: string ) => { @@ -102,7 +108,7 @@ export const getElasticsearchMetricQuery = ( const baseAggs = { aggregatedIntervals: { date_histogram: { - field: '@timestamp', + field: timefield, fixed_interval: interval, offset, extended_bounds: { @@ -174,43 +180,23 @@ export const getElasticsearchMetricQuery = ( }; }; -const getIndexPattern: ( - services: AlertServices, - sourceId?: string -) => Promise<string> = async function({ savedObjectsClient }, sourceId = 'default') { - try { - const sourceConfiguration = await savedObjectsClient.get( - infraSourceConfigurationSavedObjectType, - sourceId - ); - const { metricAlias } = convertSavedObjectToSavedSourceConfiguration( - sourceConfiguration - ).configuration; - return metricAlias || DEFAULT_INDEX_PATTERN; - } catch (e) { - if (e.output.statusCode === 404) { - return DEFAULT_INDEX_PATTERN; - } else { - throw e; - } - } -}; - const getMetric: ( services: AlertServices, params: MetricExpressionParams, index: string, + timefield: string, groupBy: string | undefined, filterQuery: string | undefined ) => Promise<Record<string, number>> = async function( - { savedObjectsClient, callCluster }, + { callCluster }, params, index, + timefield, groupBy, filterQuery ) { const { aggType } = params; - const searchBody = getElasticsearchMetricQuery(params, groupBy, filterQuery); + const searchBody = getElasticsearchMetricQuery(params, timefield, groupBy, filterQuery); try { if (groupBy) { @@ -258,47 +244,51 @@ const comparatorMap = { [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, }; -const mapToConditionsLookup = ( - list: any[], - mapFn: (value: any, index: number, array: any[]) => unknown -) => - list - .map(mapFn) - .reduce( - (result: Record<string, any>, value, i) => ({ ...result, [`condition${i}`]: value }), - {} - ); - -export const createMetricThresholdExecutor = (alertUUID: string) => +export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: string) => async function({ services, params }: AlertExecutorOptions) { - const { criteria, groupBy, filterQuery, sourceId } = params as { + const { criteria, groupBy, filterQuery, sourceId, alertOnNoData } = params as { criteria: MetricExpressionParams[]; groupBy: string | undefined; filterQuery: string | undefined; sourceId?: string; + alertOnNoData: boolean; }; + const source = await libs.sources.getSourceConfiguration( + services.savedObjectsClient, + sourceId || 'default' + ); + const config = source.configuration; const alertResults = await Promise.all( - criteria.map(criterion => - (async () => { - const index = await getIndexPattern(services, sourceId); - const currentValues = await getMetric(services, criterion, index, groupBy, filterQuery); + criteria.map(criterion => { + return (async () => { + const currentValues = await getMetric( + services, + criterion, + config.fields.timestamp, + config.metricAlias, + groupBy, + filterQuery + ); const { threshold, comparator } = criterion; const comparisonFunction = comparatorMap[comparator]; return mapValues(currentValues, value => ({ + ...criterion, + metric: criterion.metric ?? DOCUMENT_COUNT_I18N, + currentValue: value, shouldFire: value !== undefined && value !== null && comparisonFunction(value, threshold), - currentValue: value, isNoData: value === null, isError: value === undefined, })); - })() - ) + })(); + }) ); + // Because each alert result has the same group definitions, just grap the groups from the first one. const groups = Object.keys(alertResults[0]); for (const group of groups) { - const alertInstance = services.alertInstanceFactory(`${alertUUID}-${group}`); + const alertInstance = services.alertInstanceFactory(`${alertId}-${group}`); // AND logic; all criteria must be across the threshold const shouldAlertFire = alertResults.every(result => result[group].shouldFire); @@ -306,23 +296,43 @@ export const createMetricThresholdExecutor = (alertUUID: string) => // whole alert is in a No Data/Error state const isNoData = alertResults.some(result => result[group].isNoData); const isError = alertResults.some(result => result[group].isError); - if (shouldAlertFire) { + + const nextState = isError + ? AlertStates.ERROR + : isNoData + ? AlertStates.NO_DATA + : shouldAlertFire + ? AlertStates.ALERT + : AlertStates.OK; + + let reason; + if (nextState === AlertStates.ALERT) { + reason = alertResults.map(result => buildFiredAlertReason(result[group])).join('\n'); + } + if (alertOnNoData) { + if (nextState === AlertStates.NO_DATA) { + reason = alertResults + .filter(result => result[group].isNoData) + .map(result => buildNoDataAlertReason(result[group])) + .join('\n'); + } else if (nextState === AlertStates.ERROR) { + reason = alertResults + .filter(result => result[group].isError) + .map(result => buildErrorAlertReason(result[group].metric)) + .join('\n'); + } + } + if (reason) { alertInstance.scheduleActions(FIRED_ACTIONS.id, { group, - valueOf: mapToConditionsLookup(alertResults, result => result[group].currentValue), - thresholdOf: mapToConditionsLookup(criteria, criterion => criterion.threshold), - metricOf: mapToConditionsLookup(criteria, criterion => criterion.metric), + alertState: stateToAlertMessage[nextState], + reason, }); } + // Future use: ability to fetch display current alert state alertInstance.replaceState({ - alertState: isError - ? AlertStates.ERROR - : isNoData - ? AlertStates.NO_DATA - : shouldAlertFire - ? AlertStates.ALERT - : AlertStates.OK, + alertState: nextState, }); } }; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 3415ae9873bfb..23611559a184f 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -6,11 +6,11 @@ import { i18n } from '@kbn/i18n'; import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; -import { PluginSetupContract } from '../../../../../alerting/server'; +import { curry } from 'lodash'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metrics_explorer'; import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; -import { InfraBackendLibs } from '../../infra_types'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; +import { InfraBackendLibs } from '../../infra_types'; const oneOfLiterals = (arrayOfLiterals: Readonly<string[]>) => schema.string({ @@ -18,17 +18,7 @@ const oneOfLiterals = (arrayOfLiterals: Readonly<string[]>) => arrayOfLiterals.includes(value) ? undefined : `must be one of ${arrayOfLiterals.join(' | ')}`, }); -export async function registerMetricThresholdAlertType( - alertingPlugin: PluginSetupContract, - libs: InfraBackendLibs -) { - if (!alertingPlugin) { - throw new Error( - 'Cannot register metric threshold alert type. Both the actions and alerting plugins need to be enabled.' - ); - } - const alertUUID = uuid.v4(); - +export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { const baseCriterion = { threshold: schema.arrayOf(schema.number()), comparator: oneOfLiterals(Object.values(Comparator)), @@ -55,51 +45,45 @@ export async function registerMetricThresholdAlertType( } ); - const valueOfActionVariableDescription = i18n.translate( - 'xpack.infra.metrics.alerting.threshold.alerting.valueOfActionVariableDescription', + const alertStateActionVariableDescription = i18n.translate( + 'xpack.infra.metrics.alerting.threshold.alerting.alertStateActionVariableDescription', { - defaultMessage: - 'Record of the current value of the watched metric; grouped by condition, i.e valueOf.condition0, valueOf.condition1, etc.', + defaultMessage: 'Current state of the alert', } ); - const thresholdOfActionVariableDescription = i18n.translate( - 'xpack.infra.metrics.alerting.threshold.alerting.thresholdOfActionVariableDescription', + const reasonActionVariableDescription = i18n.translate( + 'xpack.infra.metrics.alerting.threshold.alerting.reasonActionVariableDescription', { defaultMessage: - 'Record of the alerting threshold; grouped by condition, i.e thresholdOf.condition0, thresholdOf.condition1, etc.', + 'A description of why the alert is in this state, including which metrics have crossed which thresholds', } ); - const metricOfActionVariableDescription = i18n.translate( - 'xpack.infra.metrics.alerting.threshold.alerting.metricOfActionVariableDescription', - { - defaultMessage: - 'Record of the watched metric; grouped by condition, i.e metricOf.condition0, metricOf.condition1, etc.', - } - ); - - alertingPlugin.registerType({ + return { id: METRIC_THRESHOLD_ALERT_TYPE_ID, name: 'Metric threshold', validate: { - params: schema.object({ - criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])), - groupBy: schema.maybe(schema.string()), - filterQuery: schema.maybe(schema.string()), - sourceId: schema.string(), - }), + params: schema.object( + { + criteria: schema.arrayOf(schema.oneOf([countCriterion, nonCountCriterion])), + groupBy: schema.maybe(schema.string()), + filterQuery: schema.maybe(schema.string()), + sourceId: schema.string(), + alertOnNoData: schema.maybe(schema.boolean()), + }, + { unknowns: 'allow' } + ), }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - executor: createMetricThresholdExecutor(alertUUID), + executor: curry(createMetricThresholdExecutor)(libs, uuid.v4()), actionVariables: { context: [ { name: 'group', description: groupActionVariableDescription }, - { name: 'valueOf', description: valueOfActionVariableDescription }, - { name: 'thresholdOf', description: thresholdOfActionVariableDescription }, - { name: 'metricOf', description: metricOfActionVariableDescription }, + { name: 'alertState', description: alertStateActionVariableDescription }, + { name: 'reason', description: reasonActionVariableDescription }, ], }, - }); + }; } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts index 66e0a363c8983..fa55f80e472de 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -53,6 +53,14 @@ export const alternateMetricResponse = { }, }; +export const emptyMetricResponse = { + aggregations: { + aggregatedIntervals: { + buckets: [], + }, + }, +}; + export const basicCompositeResponse = { aggregations: { groupings: { diff --git a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts index 9760873ff7478..44d30d7281f20 100644 --- a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts @@ -6,13 +6,16 @@ import { PluginSetupContract } from '../../../../alerting/server'; import { registerMetricThresholdAlertType } from './metric_threshold/register_metric_threshold_alert_type'; +import { registerMetricInventoryThresholdAlertType } from './inventory_metric_threshold/register_inventory_metric_threshold_alert_type'; import { registerLogThresholdAlertType } from './log_threshold/register_log_threshold_alert_type'; import { InfraBackendLibs } from '../infra_types'; const registerAlertTypes = (alertingPlugin: PluginSetupContract, libs: InfraBackendLibs) => { if (alertingPlugin) { - const registerFns = [registerMetricThresholdAlertType, registerLogThresholdAlertType]; + alertingPlugin.registerType(registerMetricThresholdAlertType(libs)); + alertingPlugin.registerType(registerMetricInventoryThresholdAlertType(libs)); + const registerFns = [registerLogThresholdAlertType]; registerFns.forEach(fn => { fn(alertingPlugin, libs); }); diff --git a/x-pack/plugins/infra/server/lib/compose/kibana.ts b/x-pack/plugins/infra/server/lib/compose/kibana.ts index f100726b5b92e..626b9d46bbde3 100644 --- a/x-pack/plugins/infra/server/lib/compose/kibana.ts +++ b/x-pack/plugins/infra/server/lib/compose/kibana.ts @@ -28,7 +28,7 @@ export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServ const sourceStatus = new InfraSourceStatus(new InfraElasticsearchSourceStatusAdapter(framework), { sources, }); - const snapshot = new InfraSnapshot({ sources, framework }); + const snapshot = new InfraSnapshot(); const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); @@ -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 07bc965dda77a..15bfbce6d512e 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 0000000000000..1df7072904f68 --- /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<typeof compositeDatasetKeyRT>; + +const logEntryDatasetBucketRT = rt.type({ + key: compositeDatasetKeyRT, +}); + +export type LogEntryDatasetBucket = rt.TypeOf<typeof logEntryDatasetBucketRT>; + +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<typeof logEntryDatasetsResponseRT>; diff --git a/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts b/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts index cf2b1e59b2a22..c75ee6d644044 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts +++ b/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts @@ -5,26 +5,23 @@ */ import { uniq } from 'lodash'; -import { RequestHandlerContext } from 'kibana/server'; import { InfraSnapshotRequestOptions } from './types'; import { getMetricsAggregations } from './query_helpers'; import { calculateMetricInterval } from '../../utils/calculate_metric_interval'; import { SnapshotModel, SnapshotModelMetricAggRT } from '../../../common/inventory_models/types'; -import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; import { getDatasetForField } from '../../routes/metrics_explorer/lib/get_dataset_for_field'; import { InfraTimerangeInput } from '../../../common/http_api/snapshot_api'; +import { ESSearchClient } from '.'; export const createTimeRangeWithInterval = async ( - framework: KibanaFramework, - requestContext: RequestHandlerContext, + client: ESSearchClient, options: InfraSnapshotRequestOptions ): Promise<InfraTimerangeInput> => { const aggregations = getMetricsAggregations(options); - const modules = await aggregationsToModules(framework, requestContext, aggregations, options); + const modules = await aggregationsToModules(client, aggregations, options); const interval = Math.max( (await calculateMetricInterval( - framework, - requestContext, + client, { indexPattern: options.sourceConfiguration.metricAlias, timestampField: options.sourceConfiguration.fields.timestamp, @@ -43,8 +40,7 @@ export const createTimeRangeWithInterval = async ( }; const aggregationsToModules = async ( - framework: KibanaFramework, - requestContext: RequestHandlerContext, + client: ESSearchClient, aggregations: SnapshotModel, options: InfraSnapshotRequestOptions ): Promise<string[]> => { @@ -59,12 +55,7 @@ const aggregationsToModules = async ( const fields = await Promise.all( uniqueFields.map( async field => - await getDatasetForField( - framework, - requestContext, - field as string, - options.sourceConfiguration.metricAlias - ) + await getDatasetForField(client, field as string, options.sourceConfiguration.metricAlias) ) ); return fields.filter(f => f) as string[]; diff --git a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts b/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts index 07abfa5fd474a..4057ed246ccaf 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts +++ b/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts @@ -3,11 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { RequestHandlerContext } from 'src/core/server'; -import { InfraDatabaseSearchResponse } from '../adapters/framework'; -import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; -import { InfraSources } from '../sources'; +import { InfraDatabaseSearchResponse, CallWithRequestParams } from '../adapters/framework'; import { JsonObject } from '../../../common/typed_json'; import { SNAPSHOT_COMPOSITE_REQUEST_SIZE } from './constants'; @@ -31,36 +27,26 @@ import { InfraSnapshotRequestOptions } from './types'; import { createTimeRangeWithInterval } from './create_timerange_with_interval'; import { SnapshotNode } from '../../../common/http_api/snapshot_api'; +export type ESSearchClient = <Hit = {}, Aggregation = undefined>( + options: CallWithRequestParams +) => Promise<InfraDatabaseSearchResponse<Hit, Aggregation>>; export class InfraSnapshot { - constructor(private readonly libs: { sources: InfraSources; framework: KibanaFramework }) {} - public async getNodes( - requestContext: RequestHandlerContext, + client: ESSearchClient, options: InfraSnapshotRequestOptions ): Promise<{ nodes: SnapshotNode[]; interval: string }> { // Both requestGroupedNodes and requestNodeMetrics may send several requests to elasticsearch // in order to page through the results of their respective composite aggregations. // Both chains of requests are supposed to run in parallel, and their results be merged // when they have both been completed. - const timeRangeWithIntervalApplied = await createTimeRangeWithInterval( - this.libs.framework, - requestContext, - options - ); + const timeRangeWithIntervalApplied = await createTimeRangeWithInterval(client, options); const optionsWithTimerange = { ...options, timerange: timeRangeWithIntervalApplied }; - const groupedNodesPromise = requestGroupedNodes( - requestContext, - optionsWithTimerange, - this.libs.framework - ); - const nodeMetricsPromise = requestNodeMetrics( - requestContext, - optionsWithTimerange, - this.libs.framework - ); + const groupedNodesPromise = requestGroupedNodes(client, optionsWithTimerange); + const nodeMetricsPromise = requestNodeMetrics(client, optionsWithTimerange); const groupedNodeBuckets = await groupedNodesPromise; const nodeMetricBuckets = await nodeMetricsPromise; + return { nodes: mergeNodeBuckets(groupedNodeBuckets, nodeMetricBuckets, options), interval: timeRangeWithIntervalApplied.interval, @@ -77,15 +63,12 @@ const handleAfterKey = createAfterKeyHandler( input => input?.aggregations?.nodes?.after_key ); -const callClusterFactory = (framework: KibanaFramework, requestContext: RequestHandlerContext) => ( - opts: any -) => - framework.callWithRequest<{}, InfraSnapshotAggregationResponse>(requestContext, 'search', opts); +const callClusterFactory = (search: ESSearchClient) => (opts: any) => + search<{}, InfraSnapshotAggregationResponse>(opts); const requestGroupedNodes = async ( - requestContext: RequestHandlerContext, - options: InfraSnapshotRequestOptions, - framework: KibanaFramework + client: ESSearchClient, + options: InfraSnapshotRequestOptions ): Promise<InfraSnapshotNodeGroupByBucket[]> => { const inventoryModel = findInventoryModel(options.nodeType); const query = { @@ -124,13 +107,12 @@ const requestGroupedNodes = async ( return await getAllCompositeData< InfraSnapshotAggregationResponse, InfraSnapshotNodeGroupByBucket - >(callClusterFactory(framework, requestContext), query, bucketSelector, handleAfterKey); + >(callClusterFactory(client), query, bucketSelector, handleAfterKey); }; const requestNodeMetrics = async ( - requestContext: RequestHandlerContext, - options: InfraSnapshotRequestOptions, - framework: KibanaFramework + client: ESSearchClient, + options: InfraSnapshotRequestOptions ): Promise<InfraSnapshotNodeMetricsBucket[]> => { const index = options.metric.type === 'logRate' @@ -175,7 +157,7 @@ const requestNodeMetrics = async ( return await getAllCompositeData< InfraSnapshotAggregationResponse, InfraSnapshotNodeMetricsBucket - >(callClusterFactory(framework, requestContext), query, bucketSelector, handleAfterKey); + >(callClusterFactory(client), query, bucketSelector, handleAfterKey); }; // buckets can be InfraSnapshotNodeGroupByBucket[] or InfraSnapshotNodeMetricsBucket[] diff --git a/x-pack/plugins/infra/server/lib/sources/index.ts b/x-pack/plugins/infra/server/lib/sources/index.ts index 9dcbe02bd064b..45348e1bfc6d8 100644 --- a/x-pack/plugins/infra/server/lib/sources/index.ts +++ b/x-pack/plugins/infra/server/lib/sources/index.ts @@ -5,6 +5,6 @@ */ export * from './defaults'; -export * from './saved_object_mappings'; +export { infraSourceConfigurationSavedObjectType } from './saved_object_type'; export * from './sources'; export * from '../../../common/http_api/source_api'; diff --git a/x-pack/plugins/infra/server/lib/sources/saved_object_mappings.ts b/x-pack/plugins/infra/server/lib/sources/saved_object_mappings.ts deleted file mode 100644 index e5b230373b7ec..0000000000000 --- a/x-pack/plugins/infra/server/lib/sources/saved_object_mappings.ts +++ /dev/null @@ -1,79 +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 { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings'; -import { InfraSavedSourceConfiguration } from '../../../common/http_api/source_api'; - -export const infraSourceConfigurationSavedObjectType = 'infrastructure-ui-source'; - -export const infraSourceConfigurationSavedObjectMappings: { - [infraSourceConfigurationSavedObjectType]: ElasticsearchMappingOf<InfraSavedSourceConfiguration>; -} = { - [infraSourceConfigurationSavedObjectType]: { - properties: { - name: { - type: 'text', - }, - description: { - type: 'text', - }, - metricAlias: { - type: 'keyword', - }, - logAlias: { - type: 'keyword', - }, - fields: { - properties: { - container: { - type: 'keyword', - }, - host: { - type: 'keyword', - }, - pod: { - type: 'keyword', - }, - tiebreaker: { - type: 'keyword', - }, - timestamp: { - type: 'keyword', - }, - }, - }, - logColumns: { - type: 'nested', - properties: { - timestampColumn: { - properties: { - id: { - type: 'keyword', - }, - }, - }, - messageColumn: { - properties: { - id: { - type: 'keyword', - }, - }, - }, - fieldColumn: { - properties: { - id: { - type: 'keyword', - }, - field: { - type: 'keyword', - }, - }, - }, - }, - }, - }, - }, -}; diff --git a/x-pack/plugins/infra/server/lib/sources/saved_object_type.ts b/x-pack/plugins/infra/server/lib/sources/saved_object_type.ts new file mode 100644 index 0000000000000..49780fc249d1f --- /dev/null +++ b/x-pack/plugins/infra/server/lib/sources/saved_object_type.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { SavedObjectsType } from 'src/core/server'; + +export const infraSourceConfigurationSavedObjectName = 'infrastructure-ui-source'; + +export const infraSourceConfigurationSavedObjectType: SavedObjectsType = { + name: infraSourceConfigurationSavedObjectName, + hidden: false, + namespaceType: 'single', + management: { + importableAndExportable: true, + }, + mappings: { + properties: { + name: { + type: 'text', + }, + description: { + type: 'text', + }, + metricAlias: { + type: 'keyword', + }, + logAlias: { + type: 'keyword', + }, + fields: { + properties: { + container: { + type: 'keyword', + }, + host: { + type: 'keyword', + }, + pod: { + type: 'keyword', + }, + tiebreaker: { + type: 'keyword', + }, + timestamp: { + type: 'keyword', + }, + }, + }, + logColumns: { + type: 'nested', + properties: { + timestampColumn: { + properties: { + id: { + type: 'keyword', + }, + }, + }, + messageColumn: { + properties: { + id: { + type: 'keyword', + }, + }, + }, + fieldColumn: { + properties: { + id: { + type: 'keyword', + }, + field: { + type: 'keyword', + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/infra/server/lib/sources/sources.ts b/x-pack/plugins/infra/server/lib/sources/sources.ts index 0368c7bfd6db8..50f725cc6e099 100644 --- a/x-pack/plugins/infra/server/lib/sources/sources.ts +++ b/x-pack/plugins/infra/server/lib/sources/sources.ts @@ -9,10 +9,10 @@ import { failure } from 'io-ts/lib/PathReporter'; import { identity, constant } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; -import { RequestHandlerContext, SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsClientContract } from 'src/core/server'; import { defaultSourceConfiguration } from './defaults'; import { NotFoundError } from './errors'; -import { infraSourceConfigurationSavedObjectType } from './saved_object_mappings'; +import { infraSourceConfigurationSavedObjectName } from './saved_object_type'; import { InfraSavedSourceConfiguration, InfraSourceConfiguration, @@ -41,7 +41,6 @@ export class InfraSources { sourceId: string ): Promise<InfraSource> { const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration(); - const savedSourceConfiguration = await this.getInternalSourceConfiguration(sourceId) .then(internalSourceConfiguration => ({ id: sourceId, @@ -79,10 +78,12 @@ export class InfraSources { return savedSourceConfiguration; } - public async getAllSourceConfigurations(requestContext: RequestHandlerContext) { + public async getAllSourceConfigurations(savedObjectsClient: SavedObjectsClientContract) { const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration(); - const savedSourceConfigurations = await this.getAllSavedSourceConfigurations(requestContext); + const savedSourceConfigurations = await this.getAllSavedSourceConfigurations( + savedObjectsClient + ); return savedSourceConfigurations.map(savedSourceConfiguration => ({ ...savedSourceConfiguration, @@ -94,7 +95,7 @@ export class InfraSources { } public async createSourceConfiguration( - requestContext: RequestHandlerContext, + savedObjectsClient: SavedObjectsClientContract, sourceId: string, source: InfraSavedSourceConfiguration ) { @@ -106,8 +107,8 @@ export class InfraSources { ); const createdSourceConfiguration = convertSavedObjectToSavedSourceConfiguration( - await requestContext.core.savedObjects.client.create( - infraSourceConfigurationSavedObjectType, + await savedObjectsClient.create( + infraSourceConfigurationSavedObjectName, pickSavedSourceConfiguration(newSourceConfiguration) as any, { id: sourceId } ) @@ -122,22 +123,22 @@ export class InfraSources { }; } - public async deleteSourceConfiguration(requestContext: RequestHandlerContext, sourceId: string) { - await requestContext.core.savedObjects.client.delete( - infraSourceConfigurationSavedObjectType, - sourceId - ); + public async deleteSourceConfiguration( + savedObjectsClient: SavedObjectsClientContract, + sourceId: string + ) { + await savedObjectsClient.delete(infraSourceConfigurationSavedObjectName, sourceId); } public async updateSourceConfiguration( - requestContext: RequestHandlerContext, + savedObjectsClient: SavedObjectsClientContract, sourceId: string, sourceProperties: InfraSavedSourceConfiguration ) { const staticDefaultSourceConfiguration = await this.getStaticDefaultSourceConfiguration(); const { configuration, version } = await this.getSourceConfiguration( - requestContext.core.savedObjects.client, + savedObjectsClient, sourceId ); @@ -147,8 +148,8 @@ export class InfraSources { ); const updatedSourceConfiguration = convertSavedObjectToSavedSourceConfiguration( - await requestContext.core.savedObjects.client.update( - infraSourceConfigurationSavedObjectType, + await savedObjectsClient.update( + infraSourceConfigurationSavedObjectName, sourceId, pickSavedSourceConfiguration(updatedSourceConfigurationAttributes) as any, { @@ -206,16 +207,16 @@ export class InfraSources { sourceId: string ) { const savedObject = await savedObjectsClient.get( - infraSourceConfigurationSavedObjectType, + infraSourceConfigurationSavedObjectName, sourceId ); return convertSavedObjectToSavedSourceConfiguration(savedObject); } - private async getAllSavedSourceConfigurations(requestContext: RequestHandlerContext) { - const savedObjects = await requestContext.core.savedObjects.client.find({ - type: infraSourceConfigurationSavedObjectType, + private async getAllSavedSourceConfigurations(savedObjectsClient: SavedObjectsClientContract) { + const savedObjects = await savedObjectsClient.find({ + type: infraSourceConfigurationSavedObjectName, }); return savedObjects.saved_objects.map(convertSavedObjectToSavedSourceConfiguration); diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index d4dfa60ac67a0..496c2b32373a8 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -28,6 +28,9 @@ import { METRICS_FEATURE, LOGS_FEATURE } from './features'; import { UsageCollector } from './usage/usage_collector'; import { InfraStaticSourceConfiguration } from '../common/http_api/source_api'; import { registerAlertTypes } from './lib/alerting'; +import { infraSourceConfigurationSavedObjectType } from './lib/sources'; +import { metricsExplorerViewSavedObjectType } from '../common/saved_objects/metrics_explorer_view'; +import { inventoryViewSavedObjectType } from '../common/saved_objects/inventory_view'; export const config = { schema: schema.object({ @@ -85,13 +88,6 @@ export class InfraServerPlugin { this.config$ = context.config.create<InfraConfig>(); } - getLibs() { - if (!this.libs) { - throw new Error('libs not set up yet'); - } - return this.libs; - } - async setup(core: CoreSetup, plugins: InfraServerPluginDeps) { await new Promise(resolve => { this.config$.subscribe(configValue => { @@ -109,16 +105,22 @@ export class InfraServerPlugin { sources, } ); - const snapshot = new InfraSnapshot({ sources, framework }); + const snapshot = new InfraSnapshot(); const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); + // register saved object types + core.savedObjects.registerType(infraSourceConfigurationSavedObjectType); + core.savedObjects.registerType(metricsExplorerViewSavedObjectType); + core.savedObjects.registerType(inventoryViewSavedObjectType); + // TODO: separate these out individually and do away with "domains" as a temporary group const domainLibs: InfraDomainLibs = { fields: new InfraFieldsDomain(new FrameworkFieldsAdapter(framework), { 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 0000000000000..d772c000986fc --- /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 727faca69298e..10c39f9552a3a 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/infra/server/routes/log_sources/configuration.ts b/x-pack/plugins/infra/server/routes/log_sources/configuration.ts index 0ce594675773c..46929954431f5 100644 --- a/x-pack/plugins/infra/server/routes/log_sources/configuration.ts +++ b/x-pack/plugins/infra/server/routes/log_sources/configuration.ts @@ -82,12 +82,12 @@ export const initLogSourceConfigurationRoutes = ({ framework, sources }: InfraBa const sourceConfigurationExists = sourceConfiguration.origin === 'stored'; const patchedSourceConfiguration = await (sourceConfigurationExists ? sources.updateSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId, patchedSourceConfigurationProperties ) : sources.createSourceConfiguration( - requestContext, + requestContext.core.savedObjects.client, sourceId, patchedSourceConfigurationProperties )); diff --git a/x-pack/plugins/infra/server/routes/metadata/index.ts b/x-pack/plugins/infra/server/routes/metadata/index.ts index fe142aa93dcda..7e3a30e1e6918 100644 --- a/x-pack/plugins/infra/server/routes/metadata/index.ts +++ b/x-pack/plugins/infra/server/routes/metadata/index.ts @@ -18,7 +18,6 @@ import { import { InfraBackendLibs } from '../../lib/infra_types'; import { getMetricMetadata } from './lib/get_metric_metadata'; import { pickFeatureName } from './lib/pick_feature_name'; -import { hasAPMData } from './lib/has_apm_data'; import { getCloudMetricsMetadata } from './lib/get_cloud_metric_metadata'; import { getNodeInfo } from './lib/get_node_info'; import { throwErrors } from '../../../common/runtime_types'; @@ -74,16 +73,13 @@ export const initMetadataRoute = (libs: InfraBackendLibs) => { const cloudMetricsFeatures = pickFeatureName(cloudMetricsMetadata.buckets).map( nameToFeature('metrics') ); - const hasAPM = await hasAPMData(framework, requestContext, configuration, nodeId, nodeType); - const apmMetricFeatures = hasAPM ? [{ name: 'apm.transaction', source: 'apm' }] : []; - const id = metricsMetadata.id; const name = metricsMetadata.name || id; return response.ok({ body: InfraMetadataRT.encode({ id, name, - features: [...metricFeatures, ...cloudMetricsFeatures, ...apmMetricFeatures], + features: [...metricFeatures, ...cloudMetricsFeatures], info, }), }); diff --git a/x-pack/plugins/infra/server/routes/metadata/lib/has_apm_data.ts b/x-pack/plugins/infra/server/routes/metadata/lib/has_apm_data.ts deleted file mode 100644 index 1f8029db80d86..0000000000000 --- a/x-pack/plugins/infra/server/routes/metadata/lib/has_apm_data.ts +++ /dev/null @@ -1,54 +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 { RequestHandlerContext } from 'src/core/server'; - -import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter'; -import { InfraSourceConfiguration } from '../../../lib/sources'; -import { findInventoryFields } from '../../../../common/inventory_models'; -import { InventoryItemType } from '../../../../common/inventory_models/types'; - -export const hasAPMData = async ( - framework: KibanaFramework, - requestContext: RequestHandlerContext, - sourceConfiguration: InfraSourceConfiguration, - nodeId: string, - nodeType: InventoryItemType -) => { - const apmIndices = await framework.plugins.apm.getApmIndices(); - const apmIndex = apmIndices['apm_oss.transactionIndices'] || 'apm-*'; - const fields = findInventoryFields(nodeType, sourceConfiguration.fields); - - // There is a bug in APM ECS data where host.name is not set. - // This will fixed with: https://github.com/elastic/apm-server/issues/2502 - const nodeFieldName = nodeType === 'host' ? 'host.hostname' : fields.id; - const params = { - allowNoIndices: true, - ignoreUnavailable: true, - terminateAfter: 1, - index: apmIndex, - body: { - size: 0, - query: { - bool: { - filter: [ - { - match: { [nodeFieldName]: nodeId }, - }, - { - exists: { field: 'service.name' }, - }, - { - exists: { field: 'transaction.type' }, - }, - ], - }, - }, - }, - }; - const response = await framework.callWithRequest<{}, {}>(requestContext, 'search', params); - return response.hits.total.value !== 0; -}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts index 66f0ca8fc706a..94e91d32b14bb 100644 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_dataset_for_field.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RequestHandlerContext } from 'kibana/server'; -import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter'; +import { ESSearchClient } from '../../../lib/snapshot'; interface EventDatasetHit { _source: { @@ -16,8 +15,7 @@ interface EventDatasetHit { } export const getDatasetForField = async ( - framework: KibanaFramework, - requestContext: RequestHandlerContext, + client: ESSearchClient, field: string, indexPattern: string ) => { @@ -33,11 +31,8 @@ export const getDatasetForField = async ( }, }; - const response = await framework.callWithRequest<EventDatasetHit>( - requestContext, - 'search', - params - ); + const response = await client<EventDatasetHit>(params); + if (response.hits.total.value === 0) { return null; } diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts index 3517800ea0dd1..a709cbdeeb680 100644 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts @@ -17,6 +17,10 @@ import { createMetricModel } from './create_metrics_model'; import { JsonObject } from '../../../../common/typed_json'; import { calculateMetricInterval } from '../../../utils/calculate_metric_interval'; import { getDatasetForField } from './get_dataset_for_field'; +import { + CallWithRequestParams, + InfraDatabaseSearchResponse, +} from '../../../lib/adapters/framework'; export const populateSeriesWithTSVBData = ( request: KibanaRequest, @@ -52,17 +56,21 @@ export const populateSeriesWithTSVBData = ( } const timerange = { min: options.timerange.from, max: options.timerange.to }; + const client = <Hit = {}, Aggregation = undefined>( + opts: CallWithRequestParams + ): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> => + framework.callWithRequest(requestContext, 'search', opts); + // Create the TSVB model based on the request options const model = createMetricModel(options); const modules = await Promise.all( uniq(options.metrics.filter(m => m.field)).map( - async m => - await getDatasetForField(framework, requestContext, m.field as string, options.indexPattern) + async m => await getDatasetForField(client, m.field as string, options.indexPattern) ) ); + const calculatedInterval = await calculateMetricInterval( - framework, - requestContext, + client, { indexPattern: options.indexPattern, timestampField: options.timerange.field, @@ -72,7 +80,9 @@ export const populateSeriesWithTSVBData = ( ); if (calculatedInterval) { - model.interval = `>=${calculatedInterval}s`; + model.interval = options.forceInterval + ? options.timerange.interval + : `>=${calculatedInterval}s`; } // Get TSVB results using the model, timerange and filters diff --git a/x-pack/plugins/infra/server/routes/snapshot/index.ts b/x-pack/plugins/infra/server/routes/snapshot/index.ts index d1dc03893a0d9..2d951d426b03a 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/index.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/index.ts @@ -13,6 +13,7 @@ import { UsageCollector } from '../../usage/usage_collector'; import { parseFilterQuery } from '../../utils/serialized_query'; import { SnapshotRequestRT, SnapshotNodeResponseRT } from '../../../common/http_api/snapshot_api'; import { throwErrors } from '../../../common/runtime_types'; +import { CallWithRequestParams, InfraDatabaseSearchResponse } from '../../lib/adapters/framework'; const escapeHatch = schema.object({}, { unknowns: 'allow' }); @@ -57,7 +58,13 @@ export const initSnapshotRoute = (libs: InfraBackendLibs) => { metric, timerange, }; - const nodesWithInterval = await libs.snapshot.getNodes(requestContext, options); + + const searchES = <Hit = {}, Aggregation = undefined>( + opts: CallWithRequestParams + ): Promise<InfraDatabaseSearchResponse<Hit, Aggregation>> => + framework.callWithRequest(requestContext, 'search', opts); + + const nodesWithInterval = await libs.snapshot.getNodes(searchES, options); return response.ok({ body: SnapshotNodeResponseRT.encode(nodesWithInterval), }); diff --git a/x-pack/plugins/infra/server/saved_objects.ts b/x-pack/plugins/infra/server/saved_objects.ts deleted file mode 100644 index 2e554300b0ecb..0000000000000 --- a/x-pack/plugins/infra/server/saved_objects.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { infraSourceConfigurationSavedObjectMappings } from './lib/sources'; -import { metricsExplorerViewSavedObjectMappings } from '../common/saved_objects/metrics_explorer_view'; -import { inventoryViewSavedObjectMappings } from '../common/saved_objects/inventory_view'; - -export const savedObjectMappings = { - ...infraSourceConfigurationSavedObjectMappings, - ...metricsExplorerViewSavedObjectMappings, - ...inventoryViewSavedObjectMappings, -}; diff --git a/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts b/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts index 7cbbdc0f2145b..43e109b009f48 100644 --- a/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts +++ b/x-pack/plugins/infra/server/utils/calculate_metric_interval.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RequestHandlerContext } from 'src/core/server'; +// import { RequestHandlerContext } from 'src/core/server'; import { findInventoryModel } from '../../common/inventory_models'; -import { KibanaFramework } from '../lib/adapters/framework/kibana_framework_adapter'; +// import { KibanaFramework } from '../lib/adapters/framework/kibana_framework_adapter'; import { InventoryItemType } from '../../common/inventory_models/types'; +import { ESSearchClient } from '../lib/snapshot'; interface Options { indexPattern: string; @@ -23,8 +24,7 @@ interface Options { * This is useful for visualizing metric modules like s3 that only send metrics once per day. */ export const calculateMetricInterval = async ( - framework: KibanaFramework, - requestContext: RequestHandlerContext, + client: ESSearchClient, options: Options, modules?: string[], nodeType?: InventoryItemType // TODO: check that this type still makes sense @@ -73,11 +73,7 @@ export const calculateMetricInterval = async ( }, }; - const resp = await framework.callWithRequest<{}, PeriodAggregationData>( - requestContext, - 'search', - query - ); + const resp = await client<{}, PeriodAggregationData>(query); // if ES doesn't return an aggregations key, something went seriously wrong. if (!resp.aggregations) { diff --git a/x-pack/plugins/infra/types/eui_experimental.d.ts b/x-pack/plugins/infra/types/eui_experimental.d.ts deleted file mode 100644 index 6b01bd8066adc..0000000000000 --- a/x-pack/plugins/infra/types/eui_experimental.d.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -declare module '@elastic/eui/lib/experimental' { - import { CommonProps } from '@elastic/eui/src/components/common'; - export type EuiSeriesChartProps = CommonProps & { - xType?: string; - stackBy?: string; - statusText?: string; - yDomain?: number[]; - showCrosshair?: boolean; - showDefaultAxis?: boolean; - enableSelectionBrush?: boolean; - crosshairValue?: number; - onSelectionBrushEnd?: (args: any) => void; - onCrosshairUpdate?: (crosshairValue: number) => void; - animateData?: boolean; - marginLeft?: number; - }; - export const EuiSeriesChart: React.FC<EuiSeriesChartProps>; - - type EuiSeriesProps = CommonProps & { - data: Array<{ x: number; y: number; y0?: number }>; - lineSize?: number; - name: string; - color?: string; - marginLeft?: number; - }; - export const EuiLineSeries: React.FC<EuiSeriesProps>; - export const EuiAreaSeries: React.FC<EuiSeriesProps>; - export const EuiBarSeries: React.FC<EuiSeriesProps>; - - type EuiYAxisProps = CommonProps & { - tickFormat: (value: number) => string; - marginLeft?: number; - }; - export const EuiYAxis: React.FC<EuiYAxisProps>; - - type EuiXAxisProps = CommonProps & { - tickFormat?: (value: number) => string; - marginLeft?: number; - }; - export const EuiXAxis: React.FC<EuiXAxisProps>; - - export interface EuiDataPoint { - seriesIndex: number; - x: number; - y: number; - originalValues: { - x: number; - y: number; - x0?: number; - }; - } - - export interface EuiFormattedValue { - title: any; - value: any; - } - type EuiCrosshairXProps = CommonProps & { - marginLeft?: number; - seriesNames: string[]; - titleFormat?: (dataPoints: EuiDataPoint[]) => EuiFormattedValue | undefined; - itemsFormat?: (dataPoints: EuiDataPoint[]) => EuiFormattedValue[]; - }; - export const EuiCrosshairX: React.FC<EuiCrosshairXProps>; -} diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index 748bb14d2d35d..b357d0c2d75f4 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/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index e3ca7635fdb40..c2f8b0f981b57 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -34,7 +34,7 @@ export interface AgentActionSOAttributes extends SavedObjectAttributes { data?: string; } -export interface AgentEvent { +export interface NewAgentEvent { type: 'STATE' | 'ERROR' | 'ACTION_RESULT' | 'ACTION'; subtype: // State | 'RUNNING' @@ -58,7 +58,11 @@ export interface AgentEvent { stream_id?: string; } -export interface AgentEventSOAttributes extends AgentEvent, SavedObjectAttributes {} +export interface AgentEvent extends NewAgentEvent { + id: string; +} + +export interface AgentEventSOAttributes extends NewAgentEvent, SavedObjectAttributes {} type MetadataValue = string | AgentMetadata; diff --git a/x-pack/plugins/ingest_manager/common/types/models/data_stream.ts b/x-pack/plugins/ingest_manager/common/types/models/data_stream.ts index 7da9bbad1b170..abc9ffcf6be6a 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/data_stream.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/data_stream.ts @@ -10,6 +10,11 @@ export interface DataStream { namespace: string; type: string; package: string; + package_version: string; last_activity: string; size_in_bytes: number; + dashboards: Array<{ + id: string; + title: string; + }>; } diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index f8779a879a049..82de90e4735f2 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -205,7 +205,6 @@ export interface RegistryVarsEntry { interface PackageAdditions { title: string; latestVersion: string; - installedVersion?: string; assets: AssetsGroupedByServiceByType; } 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 64ed95db74f4c..1105c8ee7ca82 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 @@ -4,7 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType, NewAgentAction } from '../models'; +import { + Agent, + AgentAction, + NewAgentEvent, + AgentEvent, + AgentStatus, + AgentType, + NewAgentAction, +} from '../models'; export interface GetAgentsRequest { query: { @@ -40,7 +48,7 @@ export interface PostAgentCheckinRequest { }; body: { local_metadata?: Record<string, any>; - events?: AgentEvent[]; + events?: NewAgentEvent[]; }; } @@ -152,7 +160,7 @@ export interface UpdateAgentRequest { export interface GetAgentStatusRequest { query: { - configId: string; + configId?: string; }; } diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts index 89d548d11dadb..82d7fa51b2082 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts @@ -49,16 +49,16 @@ export interface UpdateAgentConfigResponse { success: boolean; } -export interface DeleteAgentConfigsRequest { +export interface DeleteAgentConfigRequest { body: { - agentConfigIds: string[]; + agentConfigId: string; }; } -export type DeleteAgentConfigsResponse = Array<{ +export interface DeleteAgentConfigResponse { id: string; success: boolean; -}>; +} export interface GetFullAgentConfigRequest { params: { 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 c4ba8ee595acf..ae4cb4e3fce49 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 cef1a293c104b..382ea0444093d 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/components/enrollment_instructions/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx index 34233a00e630a..bc0a250b9a809 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ShellEnrollmentInstructions } from './shell'; export { ManualInstructions } from './manual'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx index b1da4583b74cc..5d2938f3e9fa0 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx @@ -4,33 +4,53 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText, EuiSpacer } from '@elastic/eui'; import React from 'react'; +import { EuiText, EuiSpacer, EuiCode, EuiCodeBlock, EuiCopy, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EnrollmentAPIKey } from '../../../types'; -export const ManualInstructions: React.FunctionComponent = () => { +interface Props { + kibanaUrl: string; + apiKey: EnrollmentAPIKey; + kibanaCASha256?: string; +} + +export const ManualInstructions: React.FunctionComponent<Props> = ({ + kibanaUrl, + apiKey, + kibanaCASha256, +}) => { + const command = ` +./elastic-agent enroll ${kibanaUrl} ${apiKey.api_key}${ + kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : '' + } +./elastic-agent run`; return ( <> <EuiText> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam vestibulum ullamcorper - turpis vitae interdum. Maecenas orci magna, auctor volutpat pellentesque eu, consectetur id - est. Nunc orci lacus, condimentum vel congue ac, fringilla eget tortor. Aliquam blandit, - nisi et congue euismod, leo lectus blandit risus, eu blandit erat metus sit amet leo. Nam - dictum lobortis condimentum. + <FormattedMessage + id="xpack.ingestManager.enrollmentInstructions.descriptionText" + defaultMessage="From the agent’s directory, run these commands to enroll and start the Elastic Agent. {enrollCommand} will write to your agent’s configuration file so that it has the correct settings. You can use this command to setup agents on more than one host." + values={{ + enrollCommand: <EuiCode>agent enroll</EuiCode>, + }} + /> </EuiText> <EuiSpacer size="m" /> - <EuiText> - Vivamus sem sapien, dictum eu tellus vel, rutrum aliquam purus. Cras quis cursus nibh. - Aliquam fermentum ipsum nec turpis luctus lobortis. Nulla facilisi. Etiam nec fringilla - urna, sed vehicula ipsum. Quisque vel pellentesque lorem, at egestas enim. Nunc semper elit - lectus, in sollicitudin erat fermentum in. Pellentesque tempus massa eget purus pharetra - blandit. - </EuiText> + <EuiCodeBlock fontSize="m"> + <pre>{command}</pre> + </EuiCodeBlock> <EuiSpacer size="m" /> - <EuiText> - Mauris congue enim nulla, nec semper est posuere non. Donec et eros eu nisi gravida - malesuada eget in velit. Morbi placerat semper euismod. Suspendisse potenti. Morbi quis - porta erat, quis cursus nulla. Aenean mauris lorem, mollis in mattis et, lobortis a lectus. - </EuiText> + <EuiCopy textToCopy={command}> + {copy => ( + <EuiButton iconType="copy" fill onClick={copy}> + <FormattedMessage + id="xpack.ingestManager.enrollmentInstructions.copyButton" + defaultMessage="Copy command" + /> + </EuiButton> + )} + </EuiCopy> </> ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx deleted file mode 100644 index cb65e31fb74b5..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React, { useState } from 'react'; -import { - EuiButtonEmpty, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiCopy, - EuiFieldText, - EuiPopover, -} from '@elastic/eui'; -import { EnrollmentAPIKey } from '../../../types'; - -// No need for i18n as these are platform names -const PLATFORMS = { - macos: 'macOS', - windows: 'Windows', - linux: 'Linux', -}; - -interface Props { - kibanaUrl: string; - kibanaCASha256?: string; - apiKey: EnrollmentAPIKey; -} - -export const ShellEnrollmentInstructions: React.FunctionComponent<Props> = ({ - kibanaUrl, - kibanaCASha256, - apiKey, -}) => { - // Platform state - const [currentPlatform, setCurrentPlatform] = useState<keyof typeof PLATFORMS>('macos'); - const [isPlatformOptionsOpen, setIsPlatformOptionsOpen] = useState<boolean>(false); - - // Build quick installation command - // const quickInstallInstructions = `${ - // kibanaCASha256 ? `CA_SHA256=${kibanaCASha256} ` : '' - // }API_KEY=${ - // apiKey.api_key - // } sh -c "$(curl ${kibanaUrl}/api/ingest_manager/fleet/install/${currentPlatform})"`; - - const quickInstallInstructions = `./elastic-agent enroll ${kibanaUrl} ${apiKey.api_key}`; - - return ( - <> - <EuiFieldText - readOnly - value={quickInstallInstructions} - fullWidth - prepend={ - <EuiPopover - button={ - <EuiButtonEmpty - size="xs" - iconType="arrowDown" - iconSide="right" - onClick={() => setIsPlatformOptionsOpen(true)} - > - {PLATFORMS[currentPlatform]} - </EuiButtonEmpty> - } - isOpen={isPlatformOptionsOpen} - closePopover={() => setIsPlatformOptionsOpen(false)} - > - <EuiContextMenuPanel - items={Object.entries(PLATFORMS).map(([platform, name]) => ( - <EuiContextMenuItem - key={platform} - onClick={() => { - setCurrentPlatform(platform as typeof currentPlatform); - setIsPlatformOptionsOpen(false); - }} - > - {name} - </EuiContextMenuItem> - ))} - /> - </EuiPopover> - } - append={ - <EuiCopy textToCopy={quickInstallInstructions}> - {copy => <EuiButtonEmpty onClick={copy} color="primary" iconType={'copy'} />} - </EuiCopy> - } - /> - </> - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx index ceb87fb048ae3..27b743d1a0890 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx @@ -64,6 +64,7 @@ export const Header: React.FC<HeaderProps> = ({ <EuiFlexGroup> {tabs ? ( <EuiFlexItem> + <EuiSpacer size="s" /> <Tabs> {tabs.map(props => ( <EuiTab {...props} key={props.id}> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/loading.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/loading.tsx index c1fae19c5dab0..9a1ffffc1ffee 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/loading.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/loading.tsx @@ -5,11 +5,12 @@ */ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiLoadingSpinnerSize } from '@elastic/eui/src/components/loading/loading_spinner'; -export const Loading: React.FunctionComponent<{}> = () => ( +export const Loading: React.FunctionComponent<{ size?: EuiLoadingSpinnerSize }> = ({ size }) => ( <EuiFlexGroup justifyContent="spaceAround"> <EuiFlexItem grow={false}> - <EuiLoadingSpinner size="xl" /> + <EuiLoadingSpinner size={size || 'xl'} /> </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx index 1c9bd9107515d..579a59cb909c6 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect } from 'react'; import { IFieldType } from 'src/plugins/data/public'; // @ts-ignore import { EuiSuggest, EuiSuggestItemProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { useDebounce, useStartDeps } from '../hooks'; import { INDEX_NAME, AGENT_SAVED_OBJECT_TYPE } from '../constants'; @@ -30,9 +31,15 @@ interface Props { value: string; fieldPrefix: string; onChange: (newValue: string) => void; + placeholder?: string; } -export const SearchBar: React.FunctionComponent<Props> = ({ value, fieldPrefix, onChange }) => { +export const SearchBar: React.FunctionComponent<Props> = ({ + value, + fieldPrefix, + onChange, + placeholder, +}) => { const { suggestions } = useSuggestions(fieldPrefix, value); // TODO fix type when correctly typed in EUI @@ -52,7 +59,12 @@ export const SearchBar: React.FunctionComponent<Props> = ({ value, fieldPrefix, // @ts-ignore value={value} icon={'search'} - placeholder={'Search'} + placeholder={ + placeholder || + i18n.translate('xpack.ingestManager.defaultSearchPlaceholderText', { + defaultMessage: 'Search', + }) + } onInputChange={onChangeSearch} onItemClick={onAutocompleteClick} suggestions={suggestions.map(suggestion => { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/table_row_actions_nested.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/table_row_actions_nested.tsx new file mode 100644 index 0000000000000..56f010e2fa774 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/table_row_actions_nested.tsx @@ -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 React, { useCallback, useState } from 'react'; +import { EuiButtonIcon, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiContextMenuProps } from '@elastic/eui/src/components/context_menu/context_menu'; + +export const TableRowActionsNested = React.memo<{ panels: EuiContextMenuProps['panels'] }>( + ({ panels }) => { + const [isOpen, setIsOpen] = useState(false); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); + const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + return ( + <EuiPopover + anchorPosition="downRight" + panelPaddingSize="none" + button={ + <EuiButtonIcon + iconType="boxesHorizontal" + onClick={handleToggleMenu} + aria-label={i18n.translate('xpack.ingestManager.genericActionsMenuText', { + defaultMessage: 'Open', + })} + /> + } + isOpen={isOpen} + closePopover={handleCloseMenu} + > + <EuiContextMenu panels={panels} initialPanelId={0} /> + </EuiPopover> + ); + } +); 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 0000000000000..ef40c171b9ca3 --- /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<void>; +} + +const FleetStatusContext = React.createContext<FleetStatus | undefined>(undefined); + +export const FleetStatusProvider: React.FC = ({ children }) => { + const config = useConfig(); + const [state, setState] = useState<FleetStatusState>({ + 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 ( + <FleetStatusContext.Provider value={{ ...state, refresh: () => sendGetStatus() }}> + {children} + </FleetStatusContext.Provider> + ); +}; + +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_kibana_link.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_link.ts new file mode 100644 index 0000000000000..f6c5b8bc03fce --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_kibana_link.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 { useCore } from './'; + +const BASE_PATH = '/app/kibana'; + +export function useKibanaLink(path: string = '/') { + const core = useCore(); + return core.http.basePath.prepend(`${BASE_PATH}#${path}`); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts index bed3f994005ad..f80c468677f48 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts @@ -18,8 +18,8 @@ import { CreateAgentConfigResponse, UpdateAgentConfigRequest, UpdateAgentConfigResponse, - DeleteAgentConfigsRequest, - DeleteAgentConfigsResponse, + DeleteAgentConfigRequest, + DeleteAgentConfigResponse, } from '../../types'; export const useGetAgentConfigs = (query: HttpFetchQuery = {}) => { @@ -75,8 +75,8 @@ export const sendUpdateAgentConfig = ( }); }; -export const sendDeleteAgentConfigs = (body: DeleteAgentConfigsRequest['body']) => { - return sendRequest<DeleteAgentConfigsResponse>({ +export const sendDeleteAgentConfig = (body: DeleteAgentConfigRequest['body']) => { + return sendRequest<DeleteAgentConfigResponse>({ path: agentConfigRouteService.getDeletePath(), method: 'post', body: JSON.stringify(body), 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 453bcf2bd81e7..cad1791af41be 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<GetAgentStatusResponse>({ + 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 c39d2a5860bf0..25cdffc5c6651 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 04fdf9f66948f..e4e84e4701f13 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<GetFleetStatusResponse>({ + path: fleetSetupRouteService.getFleetSetupPath(), + method: 'get', + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.scss b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.scss new file mode 100644 index 0000000000000..fb95b1fa8bbfc --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.scss @@ -0,0 +1,14 @@ +@import '@elastic/eui/src/components/header/variables'; +@import '@elastic/eui/src/components/nav_drawer/variables'; + +/** + * 1. Hack EUI so the bottom bar doesn't obscure the nav drawer flyout. + */ +.ingestManager__bottomBar { + z-index: 0; /* 1 */ + left: $euiNavDrawerWidthCollapsed; +} + +.ingestManager__bottomBar-isNavDrawerLocked { + left: $euiNavDrawerWidthExpanded; +} 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 6485862830d8a..f0a0c90a18c24 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,8 @@ 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 { isAllowed?: boolean; @@ -141,7 +143,9 @@ const IngestManagerApp = ({ <ConfigContext.Provider value={config}> <EuiThemeProvider darkMode={isDarkMode}> <PackageInstallProvider notifications={coreStart.notifications}> - <IngestManagerRoutes basepath={basepath} /> + <FleetStatusProvider> + <IngestManagerRoutes basepath={basepath} /> + </FleetStatusProvider> </PackageInstallProvider> </EuiThemeProvider> </ConfigContext.Provider> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx index 9ae8369abbd52..d517dde45d5e3 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx @@ -5,117 +5,92 @@ */ import React, { Fragment, useRef, useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EuiOverlayMask, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; -import { sendDeleteAgentConfigs, useCore, sendRequest } from '../../../hooks'; +import { sendDeleteAgentConfig, useCore, useConfig, sendRequest } from '../../../hooks'; interface Props { - children: (deleteAgentConfigs: deleteAgentConfigs) => React.ReactElement; + children: (deleteAgentConfig: DeleteAgentConfig) => React.ReactElement; } -export type deleteAgentConfigs = (agentConfigs: string[], onSuccess?: OnSuccessCallback) => void; +export type DeleteAgentConfig = (agentConfig: string, onSuccess?: OnSuccessCallback) => void; -type OnSuccessCallback = (agentConfigsUnenrolled: string[]) => void; +type OnSuccessCallback = (agentConfigDeleted: string) => void; export const AgentConfigDeleteProvider: React.FunctionComponent<Props> = ({ children }) => { const { notifications } = useCore(); - const [agentConfigs, setAgentConfigs] = useState<string[]>([]); + const { + fleet: { enabled: isFleetEnabled }, + } = useConfig(); + const [agentConfig, setAgentConfig] = useState<string>(); const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isLoadingAgentsCount, setIsLoadingAgentsCount] = useState<boolean>(false); const [agentsCount, setAgentsCount] = useState<number>(0); const [isLoading, setIsLoading] = useState<boolean>(false); const onSuccessCallback = useRef<OnSuccessCallback | null>(null); - const deleteAgentConfigsPrompt: deleteAgentConfigs = ( - agentConfigsToDelete, + const deleteAgentConfigPrompt: DeleteAgentConfig = ( + agentConfigToDelete, onSuccess = () => undefined ) => { - if ( - agentConfigsToDelete === undefined || - (Array.isArray(agentConfigsToDelete) && agentConfigsToDelete.length === 0) - ) { - throw new Error('No agent configs specified for deletion'); + if (!agentConfigToDelete) { + throw new Error('No agent config specified for deletion'); } setIsModalOpen(true); - setAgentConfigs(agentConfigsToDelete); - fetchAgentsCount(agentConfigsToDelete); + setAgentConfig(agentConfigToDelete); + fetchAgentsCount(agentConfigToDelete); onSuccessCallback.current = onSuccess; }; const closeModal = () => { - setAgentConfigs([]); + setAgentConfig(undefined); setIsLoading(false); setIsLoadingAgentsCount(false); setIsModalOpen(false); }; - const deleteAgentConfigs = async () => { + const deleteAgentConfig = async () => { setIsLoading(true); try { - const { data } = await sendDeleteAgentConfigs({ - agentConfigIds: agentConfigs, + const { data } = await sendDeleteAgentConfig({ + agentConfigId: agentConfig!, }); - const successfulResults = data?.filter(result => result.success) || []; - const failedResults = data?.filter(result => !result.success) || []; - if (successfulResults.length) { - const hasMultipleSuccesses = successfulResults.length > 1; - const successMessage = hasMultipleSuccesses - ? i18n.translate( - 'xpack.ingestManager.deleteAgentConfigs.successMultipleNotificationTitle', - { - defaultMessage: 'Deleted {count} agent configs', - values: { count: successfulResults.length }, - } - ) - : i18n.translate( - 'xpack.ingestManager.deleteAgentConfigs.successSingleNotificationTitle', - { - defaultMessage: "Deleted agent config '{id}'", - values: { id: successfulResults[0].id }, - } - ); - notifications.toasts.addSuccess(successMessage); + if (data?.success) { + notifications.toasts.addSuccess( + i18n.translate('xpack.ingestManager.deleteAgentConfig.successSingleNotificationTitle', { + defaultMessage: "Deleted agent config '{id}'", + values: { id: agentConfig }, + }) + ); + if (onSuccessCallback.current) { + onSuccessCallback.current(agentConfig!); + } } - if (failedResults.length) { - const hasMultipleFailures = failedResults.length > 1; - const failureMessage = hasMultipleFailures - ? i18n.translate( - 'xpack.ingestManager.deleteAgentConfigs.failureMultipleNotificationTitle', - { - defaultMessage: 'Error deleting {count} agent configs', - values: { count: failedResults.length }, - } - ) - : i18n.translate( - 'xpack.ingestManager.deleteAgentConfigs.failureSingleNotificationTitle', - { - defaultMessage: "Error deleting agent config '{id}'", - values: { id: failedResults[0].id }, - } - ); - notifications.toasts.addDanger(failureMessage); - } - - if (onSuccessCallback.current) { - onSuccessCallback.current(successfulResults.map(result => result.id)); + if (!data?.success) { + notifications.toasts.addDanger( + i18n.translate('xpack.ingestManager.deleteAgentConfig.failureSingleNotificationTitle', { + defaultMessage: "Error deleting agent config '{id}'", + values: { id: agentConfig }, + }) + ); } } catch (e) { notifications.toasts.addDanger( - i18n.translate('xpack.ingestManager.deleteAgentConfigs.fatalErrorNotificationTitle', { - defaultMessage: 'Error deleting agent configs', + i18n.translate('xpack.ingestManager.deleteAgentConfig.fatalErrorNotificationTitle', { + defaultMessage: 'Error deleting agent config', }) ); } closeModal(); }; - const fetchAgentsCount = async (agentConfigsToCheck: string[]) => { - if (isLoadingAgentsCount) { + const fetchAgentsCount = async (agentConfigToCheck: string) => { + if (!isFleetEnabled || isLoadingAgentsCount) { return; } setIsLoadingAgentsCount(true); @@ -123,7 +98,7 @@ export const AgentConfigDeleteProvider: React.FunctionComponent<Props> = ({ chil path: `/api/ingest_manager/fleet/agents`, method: 'get', query: { - kuery: `${AGENT_SAVED_OBJECT_TYPE}.config_id : (${agentConfigsToCheck.join(' or ')})`, + kuery: `${AGENT_SAVED_OBJECT_TYPE}.config_id : ${agentConfigToCheck}`, }, }); setAgentsCount(data?.total || 0); @@ -140,68 +115,61 @@ export const AgentConfigDeleteProvider: React.FunctionComponent<Props> = ({ chil <EuiConfirmModal title={ <FormattedMessage - id="xpack.ingestManager.deleteAgentConfigs.confirmModal.deleteMultipleTitle" - defaultMessage="Delete {count, plural, one {this agent config} other {# agent configs}}?" - values={{ count: agentConfigs.length }} + id="xpack.ingestManager.deleteAgentConfig.confirmModal.deleteConfigTitle" + defaultMessage="Delete this agent configuration?" /> } onCancel={closeModal} - onConfirm={deleteAgentConfigs} + onConfirm={deleteAgentConfig} cancelButtonText={ <FormattedMessage - id="xpack.ingestManager.deleteAgentConfigs.confirmModal.cancelButtonLabel" + id="xpack.ingestManager.deleteAgentConfig.confirmModal.cancelButtonLabel" defaultMessage="Cancel" /> } confirmButtonText={ isLoading || isLoadingAgentsCount ? ( <FormattedMessage - id="xpack.ingestManager.deleteAgentConfigs.confirmModal.loadingButtonLabel" + id="xpack.ingestManager.deleteAgentConfig.confirmModal.loadingButtonLabel" defaultMessage="Loading…" /> - ) : agentsCount ? ( - <FormattedMessage - id="xpack.ingestManager.deleteAgentConfigs.confirmModal.confirmAndReassignButtonLabel" - defaultMessage="Delete {agentConfigsCount, plural, one {agent config} other {agent configs}} and unenroll {agentsCount, plural, one {agent} other {agents}}" - values={{ - agentsCount, - agentConfigsCount: agentConfigs.length, - }} - /> ) : ( <FormattedMessage - id="xpack.ingestManager.deleteAgentConfigs.confirmModal.confirmButtonLabel" - defaultMessage="Delete {agentConfigsCount, plural, one {agent config} other {agent configs}}" - values={{ - agentConfigsCount: agentConfigs.length, - }} + id="xpack.ingestManager.deleteAgentConfig.confirmModal.confirmButtonLabel" + defaultMessage="Delete configuration" /> ) } buttonColor="danger" - confirmButtonDisabled={isLoading || isLoadingAgentsCount} + confirmButtonDisabled={isLoading || isLoadingAgentsCount || !!agentsCount} > {isLoadingAgentsCount ? ( <FormattedMessage - id="xpack.ingestManager.deleteAgentConfigs.confirmModal.loadingAgentsCountMessage" + id="xpack.ingestManager.deleteAgentConfig.confirmModal.loadingAgentsCountMessage" defaultMessage="Checking amount of affected agents…" /> ) : agentsCount ? ( - <FormattedMessage - id="xpack.ingestManager.deleteAgentConfigs.confirmModal.affectedAgentsMessage" - defaultMessage="{agentsCount, plural, one {# agent is} other {# agents are}} assigned {agentConfigsCount, plural, one {to this agent config} other {across these agentConfigs}}. {agentsCount, plural, one {This agent} other {These agents}} will be unenrolled." - values={{ - agentsCount, - agentConfigsCount: agentConfigs.length, - }} - /> + <EuiCallOut + color="danger" + title={i18n.translate( + 'xpack.ingestManager.deleteAgentConfig.confirmModal.affectedAgentsTitle', + { + defaultMessage: 'Configuration in use', + } + )} + > + <FormattedMessage + id="xpack.ingestManager.deleteAgentConfig.confirmModal.affectedAgentsMessage" + defaultMessage="{agentsCount, plural, one {# agent is} other {# agents are}} assigned to this agent configuration. Unassign these agents before deleting this configuration." + values={{ + agentsCount, + }} + /> + </EuiCallOut> ) : ( <FormattedMessage - id="xpack.ingestManager.deleteAgentConfigs.confirmModal.noAffectedAgentsMessage" - defaultMessage="There are no agents assigned to {agentConfigsCount, plural, one {this agent config} other {these agentConfigs}}." - values={{ - agentConfigsCount: agentConfigs.length, - }} + id="xpack.ingestManager.deleteAgentConfig.confirmModal.irreversibleMessage" + defaultMessage="This action cannot be undone." /> )} </EuiConfirmModal> @@ -211,7 +179,7 @@ export const AgentConfigDeleteProvider: React.FunctionComponent<Props> = ({ chil return ( <Fragment> - {children(deleteAgentConfigsPrompt)} + {children(deleteAgentConfigPrompt)} {renderModal()} </Fragment> ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx index 92c44d86e47c6..c55d6009074b0 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx @@ -8,8 +8,7 @@ import React, { useMemo, useState } from 'react'; import { EuiAccordion, EuiFieldText, - EuiFlexGroup, - EuiFlexItem, + EuiDescribedFormGroup, EuiForm, EuiFormRow, EuiHorizontalRule, @@ -19,11 +18,13 @@ import { EuiComboBox, EuiIconTip, EuiCheckboxGroup, + EuiButton, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; -import { NewAgentConfig } from '../../../types'; +import { NewAgentConfig, AgentConfig } from '../../../types'; +import { AgentConfigDeleteProvider } from './config_delete_provider'; interface ValidationResults { [key: string]: JSX.Element[]; @@ -36,7 +37,7 @@ const StyledEuiAccordion = styled(EuiAccordion)` `; export const agentConfigFormValidation = ( - agentConfig: Partial<NewAgentConfig> + agentConfig: Partial<NewAgentConfig | AgentConfig> ): ValidationResults => { const errors: ValidationResults = {}; @@ -53,11 +54,13 @@ export const agentConfigFormValidation = ( }; interface Props { - agentConfig: Partial<NewAgentConfig>; - updateAgentConfig: (u: Partial<NewAgentConfig>) => void; + agentConfig: Partial<NewAgentConfig | AgentConfig>; + updateAgentConfig: (u: Partial<NewAgentConfig | AgentConfig>) => void; withSysMonitoring: boolean; updateSysMonitoring: (newValue: boolean) => void; validation: ValidationResults; + isEditing?: boolean; + onDelete?: () => void; } export const AgentConfigForm: React.FunctionComponent<Props> = ({ @@ -66,9 +69,11 @@ export const AgentConfigForm: React.FunctionComponent<Props> = ({ withSysMonitoring, updateSysMonitoring, validation, + isEditing = false, + onDelete = () => {}, }) => { const [touchedFields, setTouchedFields] = useState<{ [key: string]: boolean }>({}); - const [showNamespace, setShowNamespace] = useState<boolean>(false); + const [showNamespace, setShowNamespace] = useState<boolean>(!!agentConfig.namespace); const fields: Array<{ name: 'name' | 'description' | 'namespace'; label: JSX.Element; @@ -105,209 +110,281 @@ export const AgentConfigForm: React.FunctionComponent<Props> = ({ ]; }, []); - return ( - <EuiForm> - {fields.map(({ name, label, placeholder }) => { - return ( - <EuiFormRow - fullWidth - key={name} - label={label} - error={touchedFields[name] && validation[name] ? validation[name] : null} - isInvalid={Boolean(touchedFields[name] && validation[name])} - > - <EuiFieldText - fullWidth - value={agentConfig[name]} - onChange={e => updateAgentConfig({ [name]: e.target.value })} - isInvalid={Boolean(touchedFields[name] && validation[name])} - onBlur={() => setTouchedFields({ ...touchedFields, [name]: true })} - placeholder={placeholder} - /> - </EuiFormRow> - ); - })} + const generalSettingsWrapper = (children: JSX.Element[]) => ( + <EuiDescribedFormGroup + title={ + <h4> + <FormattedMessage + id="xpack.ingestManager.configForm.generalSettingsGroupTitle" + defaultMessage="General settings" + /> + </h4> + } + description={ + <FormattedMessage + id="xpack.ingestManager.configForm.generalSettingsGroupDescription" + defaultMessage="Choose a name and description for your agent configuration." + /> + } + > + {children} + </EuiDescribedFormGroup> + ); + + const generalFields = fields.map(({ name, label, placeholder }) => { + return ( <EuiFormRow - label={ - <EuiText size="xs" color="subdued"> + fullWidth + key={name} + label={label} + error={touchedFields[name] && validation[name] ? validation[name] : null} + isInvalid={Boolean(touchedFields[name] && validation[name])} + > + <EuiFieldText + fullWidth + value={agentConfig[name]} + onChange={e => updateAgentConfig({ [name]: e.target.value })} + isInvalid={Boolean(touchedFields[name] && validation[name])} + onBlur={() => setTouchedFields({ ...touchedFields, [name]: true })} + placeholder={placeholder} + /> + </EuiFormRow> + ); + }); + + const advancedOptionsContent = ( + <> + <EuiDescribedFormGroup + title={ + <h4> <FormattedMessage - id="xpack.ingestManager.agentConfigForm.systemMonitoringFieldLabel" - defaultMessage="Optional" + id="xpack.ingestManager.agentConfigForm.namespaceFieldLabel" + defaultMessage="Default namespace" /> - </EuiText> + </h4> + } + description={ + <FormattedMessage + id="xpack.ingestManager.agentConfigForm.namespaceFieldDescription" + defaultMessage="Apply a default namespace to data sources that use this configuration. Data sources can specify their own namespaces." + /> } > <EuiSwitch showLabel={true} label={ - <> - <FormattedMessage - id="xpack.ingestManager.agentConfigForm.systemMonitoringText" - defaultMessage="Collect system metrics" - />{' '} - <EuiIconTip - content={i18n.translate( - 'xpack.ingestManager.agentConfigForm.systemMonitoringTooltipText', - { - defaultMessage: - 'Enable this option to bootstrap your configuration with a data source that collects system metrics and information.', - } - )} - position="right" - type="iInCircle" - /> - </> + <FormattedMessage + id="xpack.ingestManager.agentConfigForm.namespaceUseDefaultsFieldLabel" + defaultMessage="Use default namespace" + /> } - checked={withSysMonitoring} + checked={showNamespace} onChange={() => { - updateSysMonitoring(!withSysMonitoring); + setShowNamespace(!showNamespace); + if (showNamespace) { + updateAgentConfig({ namespace: '' }); + } }} /> - </EuiFormRow> - <EuiHorizontalRule /> - <EuiSpacer size="xs" /> - <StyledEuiAccordion - id="advancedOptions" - buttonContent={ + {showNamespace && ( + <> + <EuiSpacer size="m" /> + <EuiFormRow + fullWidth + error={touchedFields.namespace && validation.namespace ? validation.namespace : null} + isInvalid={Boolean(touchedFields.namespace && validation.namespace)} + > + <EuiComboBox + fullWidth + singleSelection + noSuggestions + selectedOptions={agentConfig.namespace ? [{ label: agentConfig.namespace }] : []} + onCreateOption={(value: string) => { + updateAgentConfig({ namespace: value }); + }} + onChange={selectedOptions => { + updateAgentConfig({ + namespace: (selectedOptions.length ? selectedOptions[0] : '') as string, + }); + }} + isInvalid={Boolean(touchedFields.namespace && validation.namespace)} + onBlur={() => setTouchedFields({ ...touchedFields, namespace: true })} + /> + </EuiFormRow> + </> + )} + </EuiDescribedFormGroup> + <EuiDescribedFormGroup + title={ + <h4> + <FormattedMessage + id="xpack.ingestManager.agentConfigForm.monitoringLabel" + defaultMessage="Agent monitoring" + /> + </h4> + } + description={ <FormattedMessage - id="xpack.ingestManager.agentConfigForm.advancedOptionsToggleLabel" - defaultMessage="Advanced options" + id="xpack.ingestManager.agentConfigForm.monitoringDescription" + defaultMessage="Collect data about your agents for debugging and tracking performance." /> } - buttonClassName="ingest-active-button" > - <EuiSpacer size="l" /> - <EuiFlexGroup> - <EuiFlexItem> - <EuiText> - <h4> - <FormattedMessage - id="xpack.ingestManager.agentConfigForm.namespaceFieldLabel" - defaultMessage="Default namespace" - /> - </h4> - </EuiText> - <EuiSpacer size="m" /> - <EuiText size="s"> + <EuiCheckboxGroup + options={[ + { + id: 'logs', + label: i18n.translate( + 'xpack.ingestManager.agentConfigForm.monitoringLogsFieldLabel', + { defaultMessage: 'Collect agent logs' } + ), + }, + { + id: 'metrics', + label: i18n.translate( + 'xpack.ingestManager.agentConfigForm.monitoringMetricsFieldLabel', + { defaultMessage: 'Collect agent metrics' } + ), + }, + ]} + idToSelectedMap={(agentConfig.monitoring_enabled || []).reduce( + (acc: { logs: boolean; metrics: boolean }, key) => { + acc[key] = true; + return acc; + }, + { logs: false, metrics: false } + )} + onChange={id => { + if (id !== 'logs' && id !== 'metrics') { + return; + } + + const hasLogs = + agentConfig.monitoring_enabled && agentConfig.monitoring_enabled.indexOf(id) >= 0; + + const previousValues = agentConfig.monitoring_enabled || []; + updateAgentConfig({ + monitoring_enabled: hasLogs + ? previousValues.filter(type => type !== id) + : [...previousValues, id], + }); + }} + /> + </EuiDescribedFormGroup> + {isEditing && 'id' in agentConfig ? ( + <EuiDescribedFormGroup + title={ + <h4> + <FormattedMessage + id="xpack.ingestManager.configForm.deleteConfigGroupTitle" + defaultMessage="Delete configuration" + /> + </h4> + } + description={ + <> + <FormattedMessage + id="xpack.ingestManager.configForm.deleteConfigGroupDescription" + defaultMessage="Existing data will not be deleted." + /> + <EuiSpacer size="s" /> + <AgentConfigDeleteProvider> + {deleteAgentConfigPrompt => { + return ( + <EuiButton + color="danger" + disabled={Boolean(agentConfig.is_default)} + onClick={() => deleteAgentConfigPrompt(agentConfig.id!, onDelete)} + > + <FormattedMessage + id="xpack.ingestManager.configForm.deleteConfigActionText" + defaultMessage="Delete configuration" + /> + </EuiButton> + ); + }} + </AgentConfigDeleteProvider> + {agentConfig.is_default ? ( + <> + <EuiSpacer size="xs" /> + <EuiText color="subdued" size="xs"> + <FormattedMessage + id="xpack.ingestManager.configForm.unableToDeleteDefaultConfigText" + defaultMessage="Default configuration cannot be deleted" + /> + </EuiText> + </> + ) : null} + </> + } + /> + ) : null} + </> + ); + + return ( + <EuiForm> + {!isEditing ? generalFields : generalSettingsWrapper(generalFields)} + {!isEditing ? ( + <EuiFormRow + label={ + <EuiText size="xs" color="subdued"> <FormattedMessage - id="xpack.ingestManager.agentConfigForm.namespaceFieldDescription" - defaultMessage="Apply a default namespace to data sources that use this configuration. Data sources can specify their own namespaces." + id="xpack.ingestManager.agentConfigForm.systemMonitoringFieldLabel" + defaultMessage="Optional" /> </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <EuiSwitch - showLabel={true} - label={ - <FormattedMessage - id="xpack.ingestManager.agentConfigForm.namespaceUseDefaultsFieldLabel" - defaultMessage="Use default namespace" - /> - } - checked={showNamespace} - onChange={() => { - setShowNamespace(!showNamespace); - if (showNamespace) { - updateAgentConfig({ namespace: '' }); - } - }} - /> - {showNamespace && ( + } + > + <EuiSwitch + showLabel={true} + label={ <> - <EuiSpacer size="m" /> - <EuiFormRow - fullWidth - error={ - touchedFields.namespace && validation.namespace ? validation.namespace : null - } - isInvalid={Boolean(touchedFields.namespace && validation.namespace)} - > - <EuiComboBox - fullWidth - singleSelection - noSuggestions - selectedOptions={ - agentConfig.namespace ? [{ label: agentConfig.namespace }] : [] - } - onCreateOption={(value: string) => { - updateAgentConfig({ namespace: value }); - }} - onChange={selectedOptions => { - updateAgentConfig({ - namespace: (selectedOptions.length ? selectedOptions[0] : '') as string, - }); - }} - isInvalid={Boolean(touchedFields.namespace && validation.namespace)} - onBlur={() => setTouchedFields({ ...touchedFields, namespace: true })} - /> - </EuiFormRow> - </> - )} - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="m" /> - <EuiFlexGroup> - <EuiFlexItem> - <EuiText> - <h4> <FormattedMessage - id="xpack.ingestManager.agentConfigForm.monitoringLabel" - defaultMessage="Agent monitoring" + id="xpack.ingestManager.agentConfigForm.systemMonitoringText" + defaultMessage="Collect system metrics" + />{' '} + <EuiIconTip + content={i18n.translate( + 'xpack.ingestManager.agentConfigForm.systemMonitoringTooltipText', + { + defaultMessage: + 'Enable this option to bootstrap your configuration with a data source that collects system metrics and information.', + } + )} + position="right" + type="iInCircle" /> - </h4> - </EuiText> - <EuiSpacer size="m" /> - <EuiText size="s"> + </> + } + checked={withSysMonitoring} + onChange={() => { + updateSysMonitoring(!withSysMonitoring); + }} + /> + </EuiFormRow> + ) : null} + {!isEditing ? ( + <> + <EuiHorizontalRule /> + <EuiSpacer size="xs" /> + <StyledEuiAccordion + id="advancedOptions" + buttonContent={ <FormattedMessage - id="xpack.ingestManager.agentConfigForm.monitoringDescription" - defaultMessage="Collect data about your agents for debugging and tracking performance." + id="xpack.ingestManager.agentConfigForm.advancedOptionsToggleLabel" + defaultMessage="Advanced options" /> - </EuiText> - </EuiFlexItem> - <EuiFlexItem> - <EuiCheckboxGroup - options={[ - { - id: 'logs', - label: i18n.translate( - 'xpack.ingestManager.agentConfigForm.monitoringLogsFieldLabel', - { defaultMessage: 'Collect agent logs' } - ), - }, - { - id: 'metrics', - label: i18n.translate( - 'xpack.ingestManager.agentConfigForm.monitoringMetricsFieldLabel', - { defaultMessage: 'Collect agent metrics' } - ), - }, - ]} - idToSelectedMap={(agentConfig.monitoring_enabled || []).reduce( - (acc: { logs: boolean; metrics: boolean }, key) => { - acc[key] = true; - return acc; - }, - { logs: false, metrics: false } - )} - onChange={id => { - if (id !== 'logs' && id !== 'metrics') { - return; - } - - const hasLogs = - agentConfig.monitoring_enabled && agentConfig.monitoring_enabled.indexOf(id) >= 0; - - const previousValues = agentConfig.monitoring_enabled || []; - updateAgentConfig({ - monitoring_enabled: hasLogs - ? previousValues.filter(type => type !== id) - : [...previousValues, id], - }); - }} - /> - </EuiFlexItem> - </EuiFlexGroup> - </StyledEuiAccordion> + } + buttonClassName="ingest-active-button" + > + <EuiSpacer size="l" /> + {advancedOptionsContent} + </StyledEuiAccordion> + </> + ) : ( + advancedOptionsContent + )} </EuiForm> ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/confirm_deploy_modal.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/confirm_deploy_modal.tsx new file mode 100644 index 0000000000000..a503beeffa8b4 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/confirm_deploy_modal.tsx @@ -0,0 +1,72 @@ +/* + * 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 { EuiCallOut, EuiOverlayMask, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { AgentConfig } from '../../../types'; + +export const ConfirmDeployConfigModal: React.FunctionComponent<{ + onConfirm: () => void; + onCancel: () => void; + agentCount: number; + agentConfig: AgentConfig; +}> = ({ onConfirm, onCancel, agentCount, agentConfig }) => { + return ( + <EuiOverlayMask> + <EuiConfirmModal + title={ + <FormattedMessage + id="xpack.ingestManager.agentConfig.confirmModalTitle" + defaultMessage="Save and deploy changes" + /> + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + <FormattedMessage + id="xpack.ingestManager.agentConfig.confirmModalCancelButtonLabel" + defaultMessage="Cancel" + /> + } + confirmButtonText={ + <FormattedMessage + id="xpack.ingestManager.agentConfig.confirmModalConfirmButtonLabel" + defaultMessage="Save and deploy changes" + /> + } + buttonColor="primary" + > + <EuiCallOut + iconType="iInCircle" + title={i18n.translate('xpack.ingestManager.agentConfig.confirmModalCalloutTitle', { + defaultMessage: + 'This action will update {agentCount, plural, one {# agent} other {# agents}}', + values: { + agentCount, + }, + })} + > + <FormattedMessage + id="xpack.ingestManager.agentConfig.confirmModalCalloutDescription" + defaultMessage="Fleet has detected that the selected agent configuration, {configName}, is already in use by + some of your agents. As a result of this action, Fleet will deploy updates to all agents + that use this configuration." + values={{ + configName: <b>{agentConfig.name}</b>, + }} + /> + </EuiCallOut> + <EuiSpacer size="l" /> + <FormattedMessage + id="xpack.ingestManager.agentConfig.confirmModalDescription" + defaultMessage="This action can not be undone. Are you sure you wish to continue?" + /> + </EuiConfirmModal> + </EuiOverlayMask> + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts index a0fdc656dd7ed..c1811b99588a8 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts @@ -7,3 +7,4 @@ export { AgentConfigForm, agentConfigFormValidation } from './config_form'; export { AgentConfigDeleteProvider } from './config_delete_provider'; export { LinkedAgentCount } from './linked_agent_count'; +export { ConfirmDeployConfigModal } from './confirm_deploy_modal'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/linked_agent_count.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/linked_agent_count.tsx index ec66108c60f68..3860439f26d44 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/linked_agent_count.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/linked_agent_count.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiLink } from '@elastic/eui'; import { useLink } from '../../../hooks'; -import { FLEET_AGENTS_PATH } from '../../../constants'; +import { FLEET_AGENTS_PATH, AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; export const LinkedAgentCount = memo<{ count: number; agentConfigId: string }>( ({ count, agentConfigId }) => { @@ -21,7 +21,7 @@ export const LinkedAgentCount = memo<{ count: number; agentConfigId: string }>( /> ); return count > 0 ? ( - <EuiLink href={`${FLEET_URI}?kuery=agents.config_id : ${agentConfigId}`}> + <EuiLink href={`${FLEET_URI}?kuery=${AGENT_SAVED_OBJECT_TYPE}.config_id : ${agentConfigId}`}> {displayValue} </EuiLink> ) : ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/confirm_modal.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/confirm_modal.tsx deleted file mode 100644 index aa7eab8f5be8d..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/confirm_modal.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 { EuiCallOut, EuiOverlayMask, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { AgentConfig } from '../../../../types'; - -export const ConfirmCreateDatasourceModal: React.FunctionComponent<{ - onConfirm: () => void; - onCancel: () => void; - agentCount: number; - agentConfig: AgentConfig; -}> = ({ onConfirm, onCancel, agentCount, agentConfig }) => { - return ( - <EuiOverlayMask> - <EuiConfirmModal - title={ - <FormattedMessage - id="xpack.ingestManager.createDatasource.confirmModalTitle" - defaultMessage="Save and deploy changes" - /> - } - onCancel={onCancel} - onConfirm={onConfirm} - cancelButtonText={ - <FormattedMessage - id="xpack.ingestManager.deleteApiKeys.confirmModal.cancelButtonLabel" - defaultMessage="Cancel" - /> - } - confirmButtonText={ - <FormattedMessage - id="xpack.ingestManager.createDatasource.confirmModalConfirmButtonLabel" - defaultMessage="Save and deploy changes" - /> - } - buttonColor="primary" - > - <EuiCallOut - iconType="iInCircle" - title={i18n.translate('xpack.ingestManager.createDatasource.confirmModalCalloutTitle', { - defaultMessage: - 'This action will update {agentCount, plural, one {# agent} other {# agents}}', - values: { - agentCount, - }, - })} - > - <FormattedMessage - id="xpack.ingestManager.createDatasource.confirmModalCalloutDescription" - defaultMessage="Fleet has detected that the selected agent configuration, {configName}, is already in use by - some of your agents. As a result of this action, Fleet will deploy updates to all agents - that use this configuration." - values={{ - configName: <b>{agentConfig.name}</b>, - }} - /> - </EuiCallOut> - <EuiSpacer size="l" /> - <FormattedMessage - id="xpack.ingestManager.createDatasource.confirmModalDescription" - defaultMessage="This action can not be undone. Are you sure you wish to continue?" - /> - </EuiConfirmModal> - </EuiOverlayMask> - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts index aa564690a6092..3bfca75668911 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts @@ -5,5 +5,4 @@ */ export { CreateDatasourcePageLayout } from './layout'; export { DatasourceInputPanel } from './datasource_input_panel'; -export { ConfirmCreateDatasourceModal } from './confirm_modal'; export { DatasourceInputVarField } from './datasource_input_var_field'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx index f1e3fea6a0742..ccd2bc75fe223 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx @@ -29,7 +29,7 @@ export const CreateDatasourcePageLayout: React.FunctionComponent<{ const leftColumn = ( <EuiFlexGroup direction="column" gutterSize="s" alignItems="flexStart"> <EuiFlexItem> - <EuiButtonEmpty size="s" iconType="arrowLeft" flush="left" href={cancelUrl}> + <EuiButtonEmpty size="xs" iconType="arrowLeft" flush="left" href={cancelUrl}> <FormattedMessage id="xpack.ingestManager.createDatasource.cancelLinkText" defaultMessage="Cancel" diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx index 8e7042c1275ad..5b7553dd8cf92 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx @@ -27,7 +27,8 @@ import { sendGetAgentStatus, } from '../../../hooks'; import { useLinks as useEPMLinks } from '../../epm/hooks'; -import { CreateDatasourcePageLayout, ConfirmCreateDatasourceModal } from './components'; +import { ConfirmDeployConfigModal } from '../components'; +import { CreateDatasourcePageLayout } from './components'; import { CreateDatasourceFrom, DatasourceFormState } from './types'; import { DatasourceValidationResults, validateDatasource, validationHasErrors } from './services'; import { StepSelectPackage } from './step_select_package'; @@ -36,7 +37,10 @@ import { StepConfigureDatasource } from './step_configure_datasource'; import { StepDefineDatasource } from './step_define_datasource'; export const CreateDatasourcePage: React.FunctionComponent = () => { - const { notifications } = useCore(); + const { + notifications, + chrome: { getIsNavDrawerLocked$ }, + } = useCore(); const { fleet: { enabled: isFleetEnabled }, } = useConfig(); @@ -45,6 +49,15 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { } = useRouteMatch(); const history = useHistory(); const from: CreateDatasourceFrom = configId ? 'config' : 'package'; + const [isNavDrawerLocked, setIsNavDrawerLocked] = useState(false); + + useEffect(() => { + const subscription = getIsNavDrawerLocked$().subscribe((newIsNavDrawerLocked: boolean) => { + setIsNavDrawerLocked(newIsNavDrawerLocked); + }); + + return () => subscription.unsubscribe(); + }); // Agent config and package info states const [agentConfig, setAgentConfig] = useState<AgentConfig>(); @@ -269,7 +282,7 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { return ( <CreateDatasourcePageLayout {...layoutProps}> {formState === 'CONFIRM' && agentConfig && ( - <ConfirmCreateDatasourceModal + <ConfirmDeployConfigModal agentCount={agentCount} agentConfig={agentConfig} onConfirm={onSubmit} @@ -278,7 +291,14 @@ export const CreateDatasourcePage: React.FunctionComponent = () => { )} <EuiSteps steps={steps} /> <EuiSpacer size="l" /> - <EuiBottomBar css={{ zIndex: 5 }} paddingSize="s"> + <EuiBottomBar + css={{ zIndex: 5 }} + className={ + isNavDrawerLocked + ? 'ingestManager__bottomBar-isNavDrawerLocked' + : 'ingestManager__bottomBar' + } + > <EuiFlexGroup gutterSize="s" justifyContent="flexEnd"> <EuiFlexItem grow={false}> <EuiButtonEmpty color="ghost" href={cancelUrl}> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/config_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/config_form.tsx deleted file mode 100644 index c4f8d944ceb14..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/config_form.tsx +++ /dev/null @@ -1,94 +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, { useState } from 'react'; -import { EuiFieldText, EuiForm, EuiFormRow } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { AgentConfig } from '../../../../types'; - -interface ValidationResults { - [key: string]: JSX.Element[]; -} - -export const configFormValidation = (config: Partial<AgentConfig>): ValidationResults => { - const errors: ValidationResults = {}; - - if (!config.name?.trim()) { - errors.name = [ - <FormattedMessage - id="xpack.ingestManager.configForm.nameRequiredErrorMessage" - defaultMessage="Config name is required" - />, - ]; - } - - return errors; -}; - -interface Props { - config: Partial<AgentConfig>; - updateConfig: (u: Partial<AgentConfig>) => void; - validation: ValidationResults; -} - -export const ConfigForm: React.FunctionComponent<Props> = ({ - config, - updateConfig, - validation, -}) => { - const [touchedFields, setTouchedFields] = useState<{ [key: string]: boolean }>({}); - const fields: Array<{ name: 'name' | 'description' | 'namespace'; label: JSX.Element }> = [ - { - name: 'name', - label: ( - <FormattedMessage - id="xpack.ingestManager.configForm.nameFieldLabel" - defaultMessage="Name" - /> - ), - }, - { - name: 'description', - label: ( - <FormattedMessage - id="xpack.ingestManager.configForm.descriptionFieldLabel" - defaultMessage="Description" - /> - ), - }, - { - name: 'namespace', - label: ( - <FormattedMessage - id="xpack.ingestManager.configForm.namespaceFieldLabel" - defaultMessage="Namespace" - /> - ), - }, - ]; - - return ( - <EuiForm> - {fields.map(({ name, label }) => { - return ( - <EuiFormRow - key={name} - label={label} - error={touchedFields[name] && validation[name] ? validation[name] : null} - isInvalid={Boolean(touchedFields[name] && validation[name])} - > - <EuiFieldText - value={config[name]} - onChange={e => updateConfig({ [name]: e.target.value })} - isInvalid={Boolean(touchedFields[name] && validation[name])} - onBlur={() => setTouchedFields({ ...touchedFields, [name]: true })} - /> - </EuiFormRow> - ); - })} - </EuiForm> - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/donut_chart.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/donut_chart.tsx deleted file mode 100644 index 408ccc6e951f6..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/donut_chart.tsx +++ /dev/null @@ -1,65 +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, { useEffect, useRef } from 'react'; -import d3 from 'd3'; -import { EuiFlexItem } from '@elastic/eui'; - -interface DonutChartProps { - data: { - [key: string]: number; - }; - height: number; - width: number; -} - -export const DonutChart = ({ height, width, data }: DonutChartProps) => { - const chartElement = useRef<SVGSVGElement | null>(null); - - useEffect(() => { - if (chartElement.current !== null) { - // we must remove any existing paths before painting - d3.selectAll('g').remove(); - const svgElement = d3 - .select(chartElement.current) - .append('g') - .attr('transform', `translate(${width / 2}, ${height / 2})`); - const color = d3.scale - .ordinal() - // @ts-ignore - .domain(data) - .range(['#017D73', '#98A2B3', '#BD271E']); - const pieGenerator = d3.layout - .pie() - .value(({ value }: any) => value) - // these start/end angles will reverse the direction of the pie, - // which matches our design - .startAngle(2 * Math.PI) - .endAngle(0); - - svgElement - .selectAll('g') - // @ts-ignore - .data(pieGenerator(d3.entries(data))) - .enter() - .append('path') - .attr( - 'd', - // @ts-ignore attr does not expect a param of type Arc<Arc> but it behaves as desired - d3.svg - .arc() - .innerRadius(width * 0.28) - .outerRadius(Math.min(width, height) / 2 - 10) - ) - .attr('fill', (d: any) => color(d.data.key)); - } - }, [data, height, width]); - return ( - <EuiFlexItem grow={false}> - <svg ref={chartElement} width={width} height={height} /> - </EuiFlexItem> - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/edit_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/edit_config.tsx deleted file mode 100644 index 65eb86d7d871f..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/edit_config.tsx +++ /dev/null @@ -1,135 +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, { useState } from 'react'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiTitle, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { useCore, sendRequest } from '../../../../hooks'; -import { agentConfigRouteService } from '../../../../services'; -import { AgentConfig } from '../../../../types'; -import { ConfigForm, configFormValidation } from './config_form'; - -interface Props { - agentConfig: AgentConfig; - onClose: () => void; -} - -export const EditConfigFlyout: React.FunctionComponent<Props> = ({ - agentConfig: originalAgentConfig, - onClose, -}) => { - const { notifications } = useCore(); - const [config, setConfig] = useState<Partial<AgentConfig>>({ - name: originalAgentConfig.name, - description: originalAgentConfig.description, - }); - const [isLoading, setIsLoading] = useState<boolean>(false); - const updateConfig = (updatedFields: Partial<AgentConfig>) => { - setConfig({ - ...config, - ...updatedFields, - }); - }; - const validation = configFormValidation(config); - - const header = ( - <EuiFlyoutHeader hasBorder aria-labelledby="FleetEditConfigFlyoutTitle"> - <EuiTitle size="m"> - <h2 id="FleetEditConfigFlyoutTitle"> - <FormattedMessage - id="xpack.ingestManager.editConfig.flyoutTitle" - defaultMessage="Edit config" - /> - </h2> - </EuiTitle> - </EuiFlyoutHeader> - ); - - const body = ( - <EuiFlyoutBody> - <ConfigForm config={config} updateConfig={updateConfig} validation={validation} /> - </EuiFlyoutBody> - ); - - const footer = ( - <EuiFlyoutFooter> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty iconType="cross" onClick={onClose} flush="left"> - <FormattedMessage - id="xpack.ingestManager.editConfig.cancelButtonLabel" - defaultMessage="Cancel" - /> - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - fill - isLoading={isLoading} - disabled={isLoading || Object.keys(validation).length > 0} - onClick={async () => { - setIsLoading(true); - try { - const { error } = await sendRequest({ - path: agentConfigRouteService.getUpdatePath(originalAgentConfig.id), - method: 'put', - body: JSON.stringify(config), - }); - if (!error) { - notifications.toasts.addSuccess( - i18n.translate('xpack.ingestManager.editConfig.successNotificationTitle', { - defaultMessage: "Agent config '{name}' updated", - values: { name: config.name }, - }) - ); - } else { - notifications.toasts.addDanger( - error - ? error.message - : i18n.translate('xpack.ingestManager.editConfig.errorNotificationTitle', { - defaultMessage: 'Unable to update agent config', - }) - ); - } - } catch (e) { - notifications.toasts.addDanger( - i18n.translate('xpack.ingestManager.editConfig.errorNotificationTitle', { - defaultMessage: 'Unable to update agent config', - }) - ); - } - setIsLoading(false); - onClose(); - }} - > - <FormattedMessage - id="xpack.ingestManager.editConfig.submitButtonLabel" - defaultMessage="Update" - /> - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlyoutFooter> - ); - - return ( - <EuiFlyout onClose={onClose} size="m" maxWidth={400}> - {header} - {body} - {footer} - </EuiFlyout> - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts index 918b361a60d79..0123bd46c16e7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts @@ -4,5 +4,3 @@ * you may not use this file except in compliance with the Elastic License. */ export { DatasourcesTable } from './datasources/datasources_table'; -export { DonutChart } from './donut_chart'; -export { EditConfigFlyout } from './edit_config'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/settings/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/settings/index.tsx new file mode 100644 index 0000000000000..2d9d29bfc1ac7 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/settings/index.tsx @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useState, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import styled from 'styled-components'; +import { EuiBottomBar, EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AGENT_CONFIG_PATH } from '../../../../../constants'; +import { AgentConfig } from '../../../../../types'; +import { + useCore, + useCapabilities, + sendUpdateAgentConfig, + useConfig, + sendGetAgentStatus, +} from '../../../../../hooks'; +import { + AgentConfigForm, + agentConfigFormValidation, + ConfirmDeployConfigModal, +} from '../../../components'; +import { useConfigRefresh } from '../../hooks'; + +const FormWrapper = styled.div` + max-width: 800px; + margin-right: auto; + margin-left: auto; +`; + +export const ConfigSettingsView = memo<{ config: AgentConfig }>( + ({ config: originalAgentConfig }) => { + const { + notifications, + chrome: { getIsNavDrawerLocked$ }, + } = useCore(); + const { + fleet: { enabled: isFleetEnabled }, + } = useConfig(); + const history = useHistory(); + const hasWriteCapabilites = useCapabilities().write; + const refreshConfig = useConfigRefresh(); + const [isNavDrawerLocked, setIsNavDrawerLocked] = useState(false); + const [agentConfig, setAgentConfig] = useState<AgentConfig>({ + ...originalAgentConfig, + }); + const [isLoading, setIsLoading] = useState<boolean>(false); + const [hasChanges, setHasChanges] = useState<boolean>(false); + const [agentCount, setAgentCount] = useState<number>(0); + const [withSysMonitoring, setWithSysMonitoring] = useState<boolean>(true); + const validation = agentConfigFormValidation(agentConfig); + + useEffect(() => { + const subscription = getIsNavDrawerLocked$().subscribe((newIsNavDrawerLocked: boolean) => { + setIsNavDrawerLocked(newIsNavDrawerLocked); + }); + + return () => subscription.unsubscribe(); + }); + + const updateAgentConfig = (updatedFields: Partial<AgentConfig>) => { + setAgentConfig({ + ...agentConfig, + ...updatedFields, + }); + setHasChanges(true); + }; + + const submitUpdateAgentConfig = async () => { + setIsLoading(true); + try { + const { name, description, namespace, monitoring_enabled } = agentConfig; + const { data, error } = await sendUpdateAgentConfig(agentConfig.id, { + name, + description, + namespace, + monitoring_enabled, + }); + if (data?.success) { + notifications.toasts.addSuccess( + i18n.translate('xpack.ingestManager.editAgentConfig.successNotificationTitle', { + defaultMessage: "Successfully updated '{name}' settings", + values: { name: agentConfig.name }, + }) + ); + refreshConfig(); + setHasChanges(false); + } else { + notifications.toasts.addDanger( + error + ? error.message + : i18n.translate('xpack.ingestManager.editAgentConfig.errorNotificationTitle', { + defaultMessage: 'Unable to update agent config', + }) + ); + } + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.ingestManager.editAgentConfig.errorNotificationTitle', { + defaultMessage: 'Unable to update agent config', + }) + ); + } + setIsLoading(false); + }; + + const onSubmit = async () => { + // Retrieve agent count if fleet is enabled + if (isFleetEnabled) { + setIsLoading(true); + const { data } = await sendGetAgentStatus({ configId: agentConfig.id }); + if (data?.results.total) { + setAgentCount(data.results.total); + } else { + await submitUpdateAgentConfig(); + } + } else { + await submitUpdateAgentConfig(); + } + }; + + return ( + <FormWrapper> + {agentCount ? ( + <ConfirmDeployConfigModal + agentCount={agentCount} + agentConfig={agentConfig} + onConfirm={() => { + setAgentCount(0); + submitUpdateAgentConfig(); + }} + onCancel={() => { + setAgentCount(0); + setIsLoading(false); + }} + /> + ) : null} + <AgentConfigForm + agentConfig={agentConfig} + updateAgentConfig={updateAgentConfig} + withSysMonitoring={withSysMonitoring} + updateSysMonitoring={newValue => setWithSysMonitoring(newValue)} + validation={validation} + isEditing={true} + onDelete={() => { + history.push(AGENT_CONFIG_PATH); + }} + /> + {hasChanges ? ( + <EuiBottomBar + css={{ zIndex: 5 }} + className={ + isNavDrawerLocked + ? 'ingestManager__bottomBar-isNavDrawerLocked' + : 'ingestManager__bottomBar' + } + > + <EuiFlexGroup justifyContent="spaceBetween" alignItems="center"> + <EuiFlexItem> + <FormattedMessage + id="xpack.ingestManager.editAgentConfig.unsavedChangesText" + defaultMessage="You have unsaved changes" + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiFlexGroup gutterSize="s" justifyContent="flexEnd"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + color="ghost" + onClick={() => { + setAgentConfig({ ...originalAgentConfig }); + setHasChanges(false); + }} + > + <FormattedMessage + id="xpack.ingestManager.editAgentConfig.cancelButtonText" + defaultMessage="Cancel" + /> + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + onClick={onSubmit} + isLoading={isLoading} + isDisabled={ + !hasWriteCapabilites || isLoading || Object.keys(validation).length > 0 + } + iconType="save" + color="primary" + fill + > + {isLoading ? ( + <FormattedMessage + id="xpack.ingestManager.editAgentConfig.savingButtonText" + defaultMessage="Saving…" + /> + ) : ( + <FormattedMessage + id="xpack.ingestManager.editAgentConfig.saveButtonText" + defaultMessage="Save changes" + /> + )} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiBottomBar> + ) : null} + </FormWrapper> + ); + } +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx index f1d7bd5dbc039..39fa8c6ee8701 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx @@ -6,23 +6,9 @@ import React, { memo } from 'react'; import { dump } from 'js-yaml'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiTitle, - EuiSpacer, - EuiText, - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { AgentConfig } from '../../../../../../../../common/types/models'; -import { - useGetOneAgentConfigFull, - useGetEnrollmentAPIKeys, - useGetOneEnrollmentAPIKey, - useCore, -} from '../../../../../hooks'; -import { ShellEnrollmentInstructions } from '../../../../../components/enrollment_instructions'; +import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { AgentConfig } from '../../../../../types'; +import { useGetOneAgentConfigFull } from '../../../../../hooks'; import { Loading } from '../../../../../components'; const CONFIG_KEYS_ORDER = [ @@ -38,14 +24,7 @@ const CONFIG_KEYS_ORDER = [ ]; export const ConfigYamlView = memo<{ config: AgentConfig }>(({ config }) => { - const core = useCore(); - const fullConfigRequest = useGetOneAgentConfigFull(config.id); - const apiKeysRequest = useGetEnrollmentAPIKeys({ - page: 1, - perPage: 1000, - }); - const apiKeyRequest = useGetOneEnrollmentAPIKey(apiKeysRequest.data?.list?.[0]?.id); if (fullConfigRequest.isLoading && !fullConfigRequest.data) { return <Loading />; @@ -72,30 +51,6 @@ export const ConfigYamlView = memo<{ config: AgentConfig }>(({ config }) => { })} </EuiCodeBlock> </EuiFlexItem> - {apiKeyRequest.data && ( - <EuiFlexItem grow={3}> - <EuiTitle size="s"> - <h3> - <FormattedMessage - id="xpack.ingestManager.yamlConfig.instructionTittle" - defaultMessage="Enroll with fleet" - /> - </h3> - </EuiTitle> - <EuiSpacer size="m" /> - <EuiText size="s"> - <FormattedMessage - id="xpack.ingestManager.yamlConfig.instructionDescription" - defaultMessage="To enroll an agent with this configuration, copy and run the following command on your host." - /> - </EuiText> - <EuiSpacer size="m" /> - <ShellEnrollmentInstructions - apiKey={apiKeyRequest.data.item} - kibanaUrl={`${window.location.origin}${core.http.basePath.get()}`} - /> - </EuiFlexItem> - )} </EuiFlexGroup> ); }); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/index.ts index 19be93676a734..76c6d64eb9e07 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ export { useGetAgentStatus, AgentStatusRefreshContext } from './use_agent_status'; -export { ConfigRefreshContext } from './use_config'; +export { ConfigRefreshContext, useConfigRefresh } from './use_config'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx index 450f86df5c03a..21695bcc6c127 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, memo, useCallback, useMemo, useState } from 'react'; +import React, { memo, useMemo, useState } from 'react'; import { Redirect, useRouteMatch, Switch, Route } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; @@ -13,7 +13,6 @@ import { EuiCallOut, EuiText, EuiSpacer, - EuiTitle, EuiButtonEmpty, EuiI18nNumber, EuiDescriptionList, @@ -26,12 +25,12 @@ import { useGetOneAgentConfig } from '../../../hooks'; import { Loading } from '../../../components'; import { WithHeaderLayout } from '../../../layouts'; import { ConfigRefreshContext, useGetAgentStatus, AgentStatusRefreshContext } from './hooks'; -import { EditConfigFlyout } from './components'; import { LinkedAgentCount } from '../components'; import { useAgentConfigLink } from './hooks/use_details_uri'; import { DETAILS_ROUTER_PATH, DETAILS_ROUTER_SUB_PATH } from './constants'; import { ConfigDatasourcesView } from './components/datasources'; import { ConfigYamlView } from './components/yaml'; +import { ConfigSettingsView } from './components/settings'; const Divider = styled.div` width: 0; @@ -70,56 +69,42 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { const configDetailsYamlLink = useAgentConfigLink('details-yaml', { configId }); const configDetailsSettingsLink = useAgentConfigLink('details-settings', { configId }); - // Flyout states - const [isEditConfigFlyoutOpen, setIsEditConfigFlyoutOpen] = useState<boolean>(false); - - const refreshData = useCallback(() => { - refreshAgentConfig(); - refreshAgentStatus(); - }, [refreshAgentConfig, refreshAgentStatus]); - const headerLeftContent = useMemo( () => ( - <React.Fragment> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiFlexGroup gutterSize="s" alignItems="center"> - <EuiFlexItem grow={false}> - <div> - <EuiButtonEmpty iconType="arrowLeft" href={configListLink} flush="left" size="xs"> - <FormattedMessage - id="xpack.ingestManager.configDetails.viewAgentListTitle" - defaultMessage="View all agent configurations" - /> - </EuiButtonEmpty> - </div> - <EuiTitle size="l"> - <h1> - {(agentConfig && agentConfig.name) || ( - <FormattedMessage - id="xpack.ingestManager.configDetails.configDetailsTitle" - defaultMessage="Config '{id}'" - values={{ - id: configId, - }} - /> - )} - </h1> - </EuiTitle> - </EuiFlexItem> - </EuiFlexGroup> - {agentConfig && agentConfig.description ? ( - <Fragment> - <EuiSpacer size="s" /> - <EuiText color="subdued" size="s"> - {agentConfig.description} - </EuiText> - </Fragment> - ) : null} + <EuiFlexGroup direction="column" gutterSize="s" alignItems="flexStart"> + <EuiFlexItem> + <EuiButtonEmpty iconType="arrowLeft" href={configListLink} flush="left" size="xs"> + <FormattedMessage + id="xpack.ingestManager.configDetails.viewAgentListTitle" + defaultMessage="View all agent configurations" + /> + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem> + <EuiText> + <h1> + {(agentConfig && agentConfig.name) || ( + <FormattedMessage + id="xpack.ingestManager.configDetails.configDetailsTitle" + defaultMessage="Config '{id}'" + values={{ + id: configId, + }} + /> + )} + </h1> + </EuiText> + </EuiFlexItem> + + {agentConfig && agentConfig.description ? ( + <EuiFlexItem> + <EuiSpacer size="s" /> + <EuiText color="subdued" size="s"> + {agentConfig.description} + </EuiText> </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer size="l" /> - </React.Fragment> + ) : null} + </EuiFlexGroup> ), [configListLink, agentConfig, configId] ); @@ -196,7 +181,7 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { return [ { id: 'datasources', - name: i18n.translate('xpack.ingestManager.configDetails.subTabs.datasouces', { + name: i18n.translate('xpack.ingestManager.configDetails.subTabs.datasourcesTabText', { defaultMessage: 'Data sources', }), href: configDetailsLink, @@ -204,15 +189,15 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { }, { id: 'yaml', - name: i18n.translate('xpack.ingestManager.configDetails.subTabs.yamlFile', { - defaultMessage: 'YAML File', + name: i18n.translate('xpack.ingestManager.configDetails.subTabs.yamlTabText', { + defaultMessage: 'YAML', }), href: configDetailsYamlLink, isSelected: tabId === 'yaml', }, { id: 'settings', - name: i18n.translate('xpack.ingestManager.configDetails.subTabs.settings', { + name: i18n.translate('xpack.ingestManager.configDetails.subTabs.settingsTabText', { defaultMessage: 'Settings', }), href: configDetailsSettingsLink, @@ -269,16 +254,6 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { rightColumn={headerRightContent} tabs={(headerTabs as unknown) as EuiTabProps[]} > - {isEditConfigFlyoutOpen ? ( - <EditConfigFlyout - onClose={() => { - setIsEditConfigFlyoutOpen(false); - refreshData(); - }} - agentConfig={agentConfig} - /> - ) : null} - <Switch> <Route path={`${DETAILS_ROUTER_PATH}/yaml`} @@ -289,8 +264,7 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { <Route path={`${DETAILS_ROUTER_PATH}/settings`} render={() => { - // TODO: Settings implementation tracked via: https://github.com/elastic/kibana/issues/57959 - return <div>Settings placeholder</div>; + return <ConfigSettingsView config={agentConfig} />; }} /> <Route diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx index d4c39f21a1ea6..5f3d59ad60f1f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_datasource_page/index.tsx @@ -29,10 +29,8 @@ import { sendGetPackageInfoByKey, } from '../../../hooks'; import { Loading, Error } from '../../../components'; -import { - CreateDatasourcePageLayout, - ConfirmCreateDatasourceModal, -} from '../create_datasource_page/components'; +import { ConfirmDeployConfigModal } from '../components'; +import { CreateDatasourcePageLayout } from '../create_datasource_page/components'; import { DatasourceValidationResults, validateDatasource, @@ -243,7 +241,7 @@ export const EditDatasourcePage: React.FunctionComponent = () => { ) : ( <> {formState === 'CONFIRM' && ( - <ConfirmCreateDatasourceModal + <ConfirmDeployConfigModal agentCount={agentCount} agentConfig={agentConfig} onConfirm={onSubmit} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/components/data_stream_row_actions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/components/data_stream_row_actions.tsx new file mode 100644 index 0000000000000..ac47387cd7ab3 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/components/data_stream_row_actions.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, { memo } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useKibanaLink } from '../../../../hooks/use_kibana_link'; +import { DataStream } from '../../../../types'; +import { TableRowActionsNested } from '../../../../components/table_row_actions_nested'; + +export const DataStreamRowActions = memo<{ datastream: DataStream }>(({ datastream }) => { + const { dashboards } = datastream; + const panels = []; + const actionNameSingular = ( + <FormattedMessage + id="xpack.ingestManager.dataStreamList.viewDashboardActionText" + defaultMessage="View dashboard" + /> + ); + const actionNamePlural = ( + <FormattedMessage + id="xpack.ingestManager.dataStreamList.viewDashboardsActionText" + defaultMessage="View dashboards" + /> + ); + + const panelTitle = i18n.translate('xpack.ingestManager.dataStreamList.viewDashboardsPanelTitle', { + defaultMessage: 'View dashboards', + }); + + if (!dashboards || dashboards.length === 0) { + panels.push({ + id: 0, + items: [ + { + icon: 'dashboardApp', + disabled: true, + name: actionNameSingular, + }, + ], + }); + } else if (dashboards.length === 1) { + panels.push({ + id: 0, + items: [ + { + icon: 'dashboardApp', + href: useKibanaLink(`/dashboard/${dashboards[0].id || ''}`), + name: actionNameSingular, + }, + ], + }); + } else { + panels.push({ + id: 0, + items: [ + { + icon: 'dashboardApp', + panel: 1, + name: actionNamePlural, + }, + ], + }); + panels.push({ + id: 1, + title: panelTitle, + items: dashboards.map(dashboard => { + return { + icon: 'dashboardApp', + href: useKibanaLink(`/dashboard/${dashboard.id || ''}`), + name: dashboard.title, + }; + }), + }); + } + + return <TableRowActionsNested panels={panels} />; +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx index d7a3e933f3bb5..cff138c6a16ca 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx @@ -20,6 +20,8 @@ import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; import { DataStream } from '../../../types'; import { WithHeaderLayout } from '../../../layouts'; import { useGetDataStreams, useStartDeps, usePagination } from '../../../hooks'; +import { PackageIcon } from '../../../components/package_icon'; +import { DataStreamRowActions } from './components/data_stream_row_actions'; const DataStreamListPageLayout: React.FunctionComponent = ({ children }) => ( <WithHeaderLayout @@ -59,7 +61,7 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { const { pagination, pageSizeOptions } = usePagination(); - // Fetch agent configs + // Fetch data streams const { isLoading, data: dataStreamsData, sendRequest } = useGetDataStreams(); // Some configs retrieved, set up table props @@ -102,6 +104,23 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.ingestManager.dataStreamList.integrationColumnTitle', { defaultMessage: 'Integration', }), + render(pkg: DataStream['package'], datastream: DataStream) { + return ( + <EuiFlexGroup gutterSize="s" alignItems="center"> + {datastream.package_version && ( + <EuiFlexItem grow={false}> + <PackageIcon + packageName={pkg} + version={datastream.package_version} + size="m" + tryApi={true} + /> + </EuiFlexItem> + )} + <EuiFlexItem grow={false}>{pkg}</EuiFlexItem> + </EuiFlexGroup> + ); + }, }, { field: 'last_activity', @@ -135,6 +154,16 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { } }, }, + { + name: i18n.translate('xpack.ingestManager.dataStreamList.actionsColumnTitle', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: (datastream: DataStream) => <DataStreamRowActions datastream={datastream} />, + }, + ], + }, ]; return cols; }, [fieldFormats]); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_darkmode.svg b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_darkmode.svg new file mode 100755 index 0000000000000..b1f86be19a080 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_darkmode.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="400" viewBox="0 0 800 400"><defs><clipPath id="a"><rect x="256.95" y="139.77" width="216.16" height="119.98" fill="#00bfb3"/></clipPath><clipPath id="b"><path d="M639.21,279.81l-45.48.88-43.52-.59C535,267.81,526.28,252,526.28,230.94a67,67,0,1,1,112.93,48.87" fill="#fa744e"/></clipPath></defs><title>Kibana-integrations-darkmode \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_lightmode.svg b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_lightmode.svg new file mode 100755 index 0000000000000..0cddcb0af6909 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_lightmode.svg @@ -0,0 +1 @@ +Kibana-integrations-lightmode \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_kibana_getting_started@2x.png b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_kibana_getting_started@2x.png deleted file mode 100644 index cad64be0b6e36..0000000000000 Binary files a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_kibana_getting_started@2x.png and /dev/null differ diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx index ab7e87b3ad06c..41d1c0ee4f965 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx @@ -46,7 +46,7 @@ export function PackageCard({ layout="horizontal" title={title || ''} description={description} - icon={} + icon={} href={url} /> ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx index d20350c5db631..cf51296d468a9 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx @@ -29,7 +29,12 @@ const Text = styled.span` type HeaderProps = PackageInfo & { iconType?: IconType }; export function Header(props: HeaderProps) { - const { iconType, name, title, version, installedVersion, latestVersion } = props; + const { iconType, name, title, version, latestVersion } = props; + + let installedVersion; + if ('savedObject' in props) { + installedVersion = props.savedObject.attributes.version; + } const hasWriteCapabilites = useCapabilities().write; const { toListView } = useLinks(); const ADD_DATASOURCE_URI = useLink(`${EPM_PATH}/${name}-${version}/add-datasource`); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx index 1f3eb2cc9362e..848d278819d1d 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx @@ -32,7 +32,10 @@ export function Detail() { const packageInfo = response.data?.response; const title = packageInfo?.title; const name = packageInfo?.name; - const installedVersion = packageInfo?.installedVersion; + let installedVersion; + if (packageInfo && 'savedObject' in packageInfo) { + installedVersion = packageInfo.savedObject.attributes.version; + } const status: InstallStatus = packageInfo?.status as any; // track install status state diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx index 4d6c02eeef8b4..15eab655adee7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx @@ -3,15 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useLinks } from '../../hooks'; +import { useCore } from '../../../../hooks'; export const HeroCopy = memo(() => { return ( - +

@@ -38,16 +41,20 @@ export const HeroCopy = memo(() => { export const HeroImage = memo(() => { const { toAssets } = useLinks(); - const ImageWrapper = styled.div` - margin-bottom: -62px; + const { uiSettings } = useCore(); + const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + + const Illustration = styled(EuiImage).attrs(props => ({ + alt: i18n.translate('xpack.ingestManager.epm.illustrationAltText', { + defaultMessage: 'Illustration of an Elastic integration', + }), + url: IS_DARK_THEME + ? toAssets('illustration_integrations_darkmode.svg') + : toAssets('illustration_integrations_lightmode.svg'), + }))` + margin-bottom: -68px; + width: 80%; `; - return ( - - - - ); + return ; }); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx index bf785147502b5..983a322de1088 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx @@ -67,29 +67,34 @@ export function EPMHomePage() { function InstalledPackages() { const { data: allPackages, isLoading: isLoadingPackages } = useGetPackages(); const [selectedCategory, setSelectedCategory] = useState(''); - const packages = - allPackages && allPackages.response && selectedCategory === '' - ? allPackages.response.filter(pkg => pkg.status === 'installed') - : []; const title = i18n.translate('xpack.ingestManager.epmList.installedTitle', { defaultMessage: 'Installed integrations', }); + const allInstalledPackages = + allPackages && allPackages.response + ? allPackages.response.filter(pkg => pkg.status === 'installed') + : []; + + const updatablePackages = allInstalledPackages.filter( + item => 'savedObject' in item && item.version > item.savedObject.attributes.version + ); + const categories = [ { id: '', title: i18n.translate('xpack.ingestManager.epmList.allFilterLinkText', { defaultMessage: 'All', }), - count: packages.length, + count: allInstalledPackages.length, }, { id: 'updates_available', title: i18n.translate('xpack.ingestManager.epmList.updatesAvailableFilterLinkText', { defaultMessage: 'Updates available', }), - count: 0, // TODO: Update with real count when available + count: updatablePackages.length, }, ]; @@ -106,7 +111,7 @@ function InstalledPackages() { isLoading={isLoadingPackages} controls={controls} title={title} - list={packages} + list={selectedCategory === 'updates_available' ? updatablePackages : allInstalledPackages} /> ); } @@ -134,7 +139,6 @@ function AvailablePackages() { }, ...(categoriesRes ? categoriesRes.response : []), ]; - const controls = categories ? ( = memo(({ agent }) => { + const hasWriteCapabilites = useCapabilities().write; + const refreshAgent = useAgentRefresh(); + const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); + const handleCloseMenu = useCallback(() => setIsActionsPopoverOpen(false), [ + setIsActionsPopoverOpen, + ]); + const handleToggleMenu = useCallback(() => setIsActionsPopoverOpen(!isActionsPopoverOpen), [ + isActionsPopoverOpen, + ]); + const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false); + + return ( + <> + {isReassignFlyoutOpen && ( + setIsReassignFlyoutOpen(false)} /> + )} + + + + } + isOpen={isActionsPopoverOpen} + closePopover={handleCloseMenu} + > + { + handleCloseMenu(); + setIsReassignFlyoutOpen(true); + }} + key="reassignConfig" + > + + , + + {unenrollAgentsPrompt => ( + { + unenrollAgentsPrompt([agent.id], 1, refreshAgent); + }} + > + + + )} + , + ]} + /> + + + ); +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx new file mode 100644 index 0000000000000..12791b69d886c --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo } from 'react'; +import { + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiFlexGroup, + EuiFlexItem, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Agent, AgentConfig } from '../../../../types'; +import { AGENT_CONFIG_DETAILS_PATH } from '../../../../constants'; +import { useLink } from '../../../../hooks'; +import { AgentHealth } from '../../components'; + +export const AgentDetailsContent: React.FunctionComponent<{ + agent: Agent; + agentConfig?: AgentConfig; +}> = memo(({ agent, agentConfig }) => { + const agentConfigUrl = useLink(AGENT_CONFIG_DETAILS_PATH); + return ( + + {[ + { + title: i18n.translate('xpack.ingestManager.agentDetails.hostNameLabel', { + defaultMessage: 'Host name', + }), + description: agent.local_metadata['host.hostname'], + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.hostIdLabel', { + defaultMessage: 'Host ID', + }), + description: agent.id, + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.statusLabel', { + defaultMessage: 'Status', + }), + description: , + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.agentConfigurationLabel', { + defaultMessage: 'Agent configuration', + }), + description: agentConfig ? ( + + {agentConfig.name || agent.config_id} + + ) : ( + agent.config_id || '-' + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.versionLabel', { + defaultMessage: 'Agent version', + }), + description: agent.local_metadata['agent.version'], + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.platformLabel', { + defaultMessage: 'Platform', + }), + description: agent.local_metadata['os.platform'], + }, + ].map(({ title, description }) => { + return ( + + + {title} + + + {description} + + + ); + })} + + ); +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx index 9168669d132a0..5be728b88c3e4 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx @@ -13,14 +13,19 @@ import { EuiButton, EuiSpacer, EuiFlexItem, - EuiTitle, + EuiBadge, + EuiText, + EuiButtonIcon, + EuiCodeBlock, } from '@elastic/eui'; +import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedTime } from '@kbn/i18n/react'; import { AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../../../constants'; import { Agent, AgentEvent } from '../../../../types'; import { usePagination, useGetOneAgentEvents } from '../../../../hooks'; import { SearchBar } from '../../../../components/search_bar'; +import { TYPE_LABEL, SUBTYPE_LABEL } from './type_labels'; function useSearch() { const [state, setState] = useState<{ search: string }>({ @@ -41,6 +46,9 @@ function useSearch() { export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ agent }) => { const { pageSizeOptions, pagination, setPagination } = usePagination(); const { search, setSearch } = useSearch(); + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{ + [key: string]: JSX.Element; + }>({}); const { isLoading, data, sendRequest } = useGetOneAgentEvents(agent.id, { page: pagination.currentPage, @@ -59,6 +67,49 @@ export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ ag pageSizeOptions, }; + const toggleDetails = (agentEvent: AgentEvent) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[agentEvent.id]) { + delete itemIdToExpandedRowMapValues[agentEvent.id]; + } else { + const details = ( +
+
+ + + + + +

{agentEvent.message}

+
+
+ {agentEvent.payload ? ( +
+ + + + + + + + + {JSON.stringify(agentEvent.payload, null, 2)} + +
+ ) : null} +
+ ); + itemIdToExpandedRowMapValues[agentEvent.id] = details; + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }; + const columns = [ { field: 'timestamp', @@ -66,40 +117,63 @@ export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ ag defaultMessage: 'Timestamp', }), render: (timestamp: string) => ( - + ), sortable: true, + width: '18%', }, { field: 'type', name: i18n.translate('xpack.ingestManager.agentEventsList.typeColumnTitle', { defaultMessage: 'Type', }), - width: '90px', + width: '10%', + render: (type: AgentEvent['type']) => + TYPE_LABEL[type] || {type}, }, { field: 'subtype', name: i18n.translate('xpack.ingestManager.agentEventsList.subtypeColumnTitle', { defaultMessage: 'Subtype', }), - width: '90px', + width: '13%', + render: (subtype: AgentEvent['subtype']) => + SUBTYPE_LABEL[subtype] || {subtype}, }, { field: 'message', name: i18n.translate('xpack.ingestManager.agentEventsList.messageColumnTitle', { defaultMessage: 'Message', }), + render: (message: string) => {message}, + truncateText: true, }, { - field: 'payload', - name: i18n.translate('xpack.ingestManager.agentEventsList.paylodColumnTitle', { - defaultMessage: 'Payload', - }), - truncateText: true, - render: (payload: any) => ( - - {payload && JSON.stringify(payload, null, 2)} - + align: RIGHT_ALIGNMENT, + width: '40px', + isExpander: true, + render: (agentEvent: AgentEvent) => ( + toggleDetails(agentEvent)} + aria-label={ + itemIdToExpandedRowMap[agentEvent.id] + ? i18n.translate('xpack.ingestManager.agentEventsList.collapseDetailsAriaLabel', { + defaultMessage: 'Hide details', + }) + : i18n.translate('xpack.ingestManager.agentEventsList.expandDetailsAriaLabel', { + defaultMessage: 'Show details', + }) + } + iconType={itemIdToExpandedRowMap[agentEvent.id] ? 'arrowUp' : 'arrowDown'} + /> ), }, ]; @@ -120,25 +194,20 @@ export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ ag return ( <> - -

- -

-
- - + = ({ ag onChange={onChange} items={list} + itemId="id" columns={columns} pagination={paginationOptions} loading={isLoading} + itemIdToExpandedRowMap={itemIdToExpandedRowMap} /> ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/details_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/details_section.tsx deleted file mode 100644 index b69dd6bcf8431..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/details_section.tsx +++ /dev/null @@ -1,206 +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, { useState, Fragment, useCallback } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiTitle, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiDescriptionList, - EuiButton, - EuiPopover, - EuiDescriptionListTitle, - EuiDescriptionListDescription, - EuiButtonEmpty, - EuiIconTip, - EuiContextMenuPanel, - EuiContextMenuItem, - EuiTextColor, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useAgentRefresh } from '../hooks'; -import { AgentMetadataFlyout } from './metadata_flyout'; -import { Agent } from '../../../../types'; -import { AgentHealth } from '../../components/agent_health'; -import { useCapabilities, useGetOneAgentConfig } from '../../../../hooks'; -import { Loading } from '../../../../components'; -import { ConnectedLink, AgentReassignConfigFlyout } from '../../components'; -import { AgentUnenrollProvider } from '../../components/agent_unenroll_provider'; - -const Item: React.FunctionComponent<{ label: string }> = ({ label, children }) => { - return ( - - - {label} - {children} - - - ); -}; - -function useFlyout() { - const [isVisible, setVisible] = useState(false); - return { - isVisible, - show: () => setVisible(true), - hide: () => setVisible(false), - }; -} - -interface Props { - agent: Agent; -} -export const AgentDetailSection: React.FunctionComponent = ({ agent }) => { - const hasWriteCapabilites = useCapabilities().write; - const metadataFlyout = useFlyout(); - const refreshAgent = useAgentRefresh(); - // Actions menu - const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); - const handleCloseMenu = useCallback(() => setIsActionsPopoverOpen(false), [ - setIsActionsPopoverOpen, - ]); - const handleToggleMenu = useCallback(() => setIsActionsPopoverOpen(!isActionsPopoverOpen), [ - isActionsPopoverOpen, - ]); - const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(false); - - // Fetch AgentConfig information - const { isLoading: isAgentConfigLoading, data: agentConfigData } = useGetOneAgentConfig( - agent.config_id - ); - - const items = [ - { - title: i18n.translate('xpack.ingestManager.agentDetails.statusLabel', { - defaultMessage: 'Status', - }), - description: , - }, - { - title: i18n.translate('xpack.ingestManager.agentDetails.idLabel', { - defaultMessage: 'ID', - }), - description: agent.id, - }, - { - title: i18n.translate('xpack.ingestManager.agentDetails.typeLabel', { - defaultMessage: 'Type', - }), - description: agent.type, - }, - { - title: i18n.translate('xpack.ingestManager.agentDetails.agentConfigLabel', { - defaultMessage: 'AgentConfig', - }), - description: isAgentConfigLoading ? ( - - ) : agentConfigData && agentConfigData.item ? ( - - {agentConfigData.item.name} - - ) : ( - - - } - />{' '} - {agent.config_id} - - ), - }, - ]; - - return ( - <> - {isReassignFlyoutOpen && ( - setIsReassignFlyoutOpen(false)} /> - )} - - - -

- -

-
-
- - - -
- } - isOpen={isActionsPopoverOpen} - closePopover={handleCloseMenu} - > - { - handleCloseMenu(); - setIsReassignFlyoutOpen(true); - }} - key="reassignConfig" - > - - , - - - {unenrollAgentsPrompt => ( - { - unenrollAgentsPrompt([agent.id], 1, refreshAgent); - }} - > - - - )} - , - ]} - /> - -
-
- - - {items.map((item, idx) => ( - - {item.description} - - ))} - - metadataFlyout.show()}>View metadata - - - {metadataFlyout.isVisible && } - - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/index.ts index 9dffa54aeaf7f..8e6ddd0959358 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ export { AgentEventsTable } from './agent_events_table'; -export { AgentDetailSection } from './details_section'; +export { AgentDetailsActionMenu } from './actions_menu'; +export { AgentDetailsContent } from './agent_details'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx new file mode 100644 index 0000000000000..e9cb59be37892 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.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 React from 'react'; +import { EuiBadge } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentEvent } from '../../../../types'; + +export const TYPE_LABEL: { [key in AgentEvent['type']]: JSX.Element } = { + STATE: ( + + + + ), + ERROR: ( + + + + ), + ACTION_RESULT: ( + + + + ), + ACTION: ( + + + + ), +}; + +export const SUBTYPE_LABEL: { [key in AgentEvent['subtype']]: JSX.Element } = { + RUNNING: ( + + + + ), + STARTING: ( + + + + ), + IN_PROGRESS: ( + + + + ), + CONFIG: ( + + + + ), + FAILED: ( + + + + ), + STOPPING: ( + + + + ), + STOPPED: ( + + + + ), + DATA_DUMP: ( + + + + ), + ACKNOWLEDGED: ( + + + + ), + UNKNOWN: ( + + + + ), +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx index f8ba829135f3c..e5d69dced7523 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx @@ -3,64 +3,229 @@ * 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 { useRouteMatch } from 'react-router-dom'; +import React, { useMemo } from 'react'; +import { useRouteMatch, Switch, Route } from 'react-router-dom'; +import styled from 'styled-components'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiText, + EuiLink, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCallOut, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AgentEventsTable, AgentDetailSection } from './components'; import { AgentRefreshContext } from './hooks'; -import { Loading } from '../../../components'; -import { useGetOneAgent } from '../../../hooks'; +import { + FLEET_AGENTS_PATH, + FLEET_AGENT_DETAIL_PATH, + AGENT_CONFIG_DETAILS_PATH, +} from '../../../constants'; +import { Loading, Error } from '../../../components'; +import { useGetOneAgent, useGetOneAgentConfig, useLink } from '../../../hooks'; import { WithHeaderLayout } from '../../../layouts'; +import { AgentHealth } from '../components'; +import { AgentEventsTable, AgentDetailsActionMenu, AgentDetailsContent } from './components'; + +const Divider = styled.div` + width: 0; + height: 100%; + border-left: ${props => props.theme.eui.euiBorderThin}; +`; export const AgentDetailsPage: React.FunctionComponent = () => { const { - params: { agentId }, - } = useRouteMatch(); - const agentRequest = useGetOneAgent(agentId, { + params: { agentId, tabId = '' }, + } = useRouteMatch<{ agentId: string; tabId?: string }>(); + const { + isLoading, + isInitialRequest, + error, + data: agentData, + sendRequest: sendAgentRequest, + } = useGetOneAgent(agentId, { pollIntervalMs: 5000, }); + const { + isLoading: isAgentConfigLoading, + data: agentConfigData, + sendRequest: sendAgentConfigRequest, + } = useGetOneAgentConfig(agentData?.item?.config_id); - if (agentRequest.isLoading && agentRequest.isInitialRequest) { - return ; - } + const agentListUrl = useLink(FLEET_AGENTS_PATH); + const agentActivityTabUrl = useLink(`${FLEET_AGENT_DETAIL_PATH}${agentId}/activity`); + const agentDetailsTabUrl = useLink(`${FLEET_AGENT_DETAIL_PATH}${agentId}/details`); + const agentConfigUrl = useLink(AGENT_CONFIG_DETAILS_PATH); - if (agentRequest.error) { - return ( - - -

- {agentRequest.error.message} -

-
-
- ); - } + const headerLeftContent = useMemo( + () => ( + + + + + + + + +

+ {agentData?.item?.local_metadata['host.hostname'] || ( + + )} +

+
+
+
+ ), + [agentData, agentId, agentListUrl] + ); - if (!agentRequest.data) { - return ( - - - - ); - } + const headerRightContent = useMemo( + () => + agentData && agentData.item ? ( + + {[ + { + label: i18n.translate('xpack.ingestManager.agentDetails.statusLabel', { + defaultMessage: 'Status', + }), + content: , + }, + { isDivider: true }, + { + label: i18n.translate('xpack.ingestManager.agentDetails.configurationLabel', { + defaultMessage: 'Configuration', + }), + content: isAgentConfigLoading ? ( + + ) : agentConfigData?.item ? ( + + {agentConfigData.item.name || agentData.item.config_id} + + ) : ( + agentData.item.config_id || '-' + ), + }, + { isDivider: true }, + { + content: , + }, + ].map((item, index) => ( + + {item.isDivider ?? false ? ( + + ) : item.label ? ( + + {item.label} + {item.content} + + ) : ( + item.content + )} + + ))} + + ) : ( + undefined + ), + [agentConfigData, agentConfigUrl, agentData, isAgentConfigLoading] + ); - const agent = agentRequest.data.item; + const headerTabs = useMemo(() => { + return [ + { + id: 'activity_log', + name: i18n.translate('xpack.ingestManager.agentDetails.subTabs.activityLogTab', { + defaultMessage: 'Activity log', + }), + href: agentActivityTabUrl, + isSelected: !tabId || tabId === 'activity', + }, + { + id: 'details', + name: i18n.translate('xpack.ingestManager.agentDetails.subTabs.detailsTab', { + defaultMessage: 'Agent details', + }), + href: agentDetailsTabUrl, + isSelected: tabId === 'details', + }, + ]; + }, [agentActivityTabUrl, agentDetailsTabUrl, tabId]); return ( - agentRequest.sendRequest() }}> - }> - + { + sendAgentRequest(); + sendAgentConfigRequest(); + }, + }} + > + + {isLoading && isInitialRequest ? ( + + ) : error ? ( + + } + error={error} + /> + ) : agentData && agentData.item ? ( + + { + return ( + + ); + }} + /> + { + return ; + }} + /> + + ) : ( + + } + error={i18n.translate( + 'xpack.ingestManager.agentDetails.agentNotFoundErrorDescription', + { + defaultMessage: 'Cannot found agent ID {agentId}', + values: { + agentId, + }, + } + )} + /> + )} ); 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 deleted file mode 100644 index dd34e7260b27b..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx +++ /dev/null @@ -1,77 +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, { useState } from 'react'; -import { - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiSpacer, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiButton, - EuiFlyoutFooter, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { AgentConfig } from '../../../../../types'; -import { APIKeySelection } from './key_selection'; -import { EnrollmentInstructions } from './instructions'; - -interface Props { - onClose: () => void; - agentConfigs: AgentConfig[]; -} - -export const AgentEnrollmentFlyout: React.FunctionComponent = ({ - onClose, - agentConfigs = [], -}) => { - const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); - - return ( - - - -

- -

-
-
- - setSelectedAPIKeyId(keyId)} - /> - - - - - - - - - - - - - - - - - -
- ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx deleted file mode 100644 index 1d2f3bd155622..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx +++ /dev/null @@ -1,128 +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, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiText, EuiButtonGroup, EuiSteps } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - ShellEnrollmentInstructions, - ManualInstructions, -} from '../../../../../components/enrollment_instructions'; -import { useCore, useGetAgents, useGetOneEnrollmentAPIKey } from '../../../../../hooks'; -import { Loading } from '../../../components'; - -interface Props { - selectedAPIKeyId: string | undefined; -} -function useNewEnrolledAgents() { - // New enrolled agents - const [timestamp] = useState(new Date().toISOString()); - const agentsRequest = useGetAgents( - { - perPage: 100, - page: 1, - showInactive: false, - }, - { - pollIntervalMs: 3000, - } - ); - return React.useMemo(() => { - if (!agentsRequest.data) { - return []; - } - - return agentsRequest.data.list.filter(agent => agent.enrolled_at >= timestamp); - }, [agentsRequest.data, timestamp]); -} - -export const EnrollmentInstructions: React.FunctionComponent = ({ selectedAPIKeyId }) => { - const core = useCore(); - const [installType, setInstallType] = useState<'quickInstall' | 'manual'>('quickInstall'); - - const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); - - const newAgents = useNewEnrolledAgents(); - if (!apiKey.data) { - return null; - } - - return ( - <> - { - setInstallType(installType === 'manual' ? 'quickInstall' : 'manual'); - }} - buttonSize="m" - isFullWidth - /> - - {installType === 'manual' ? ( - - ) : ( - - ), - }, - { - title: i18n.translate('xpack.ingestManager.agentEnrollment.stepTestAgents', { - defaultMessage: 'Test Agents', - }), - children: ( - - {!newAgents.length ? ( - <> - - - - ) : ( - <> - - - )} - - ), - }, - ]} - /> - )} - - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx deleted file mode 100644 index 67930e51418b0..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx +++ /dev/null @@ -1,203 +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, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiSelect, - EuiSpacer, - EuiText, - EuiLink, - EuiFieldText, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { AgentConfig } from '../../../../../types'; -import { useInput, useCore, sendRequest, useGetEnrollmentAPIKeys } from '../../../../../hooks'; -import { enrollmentAPIKeyRouteService } from '../../../../../services'; - -interface Props { - onKeyChange: (keyId: string | undefined) => void; - agentConfigs: AgentConfig[]; -} - -function useCreateApiKeyForm(configId: string | undefined, onSuccess: (keyId: string) => void) { - const { notifications } = useCore(); - const [isLoading, setIsLoading] = useState(false); - const apiKeyNameInput = useInput(''); - - const onSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setIsLoading(true); - try { - const res = await sendRequest({ - method: 'post', - path: enrollmentAPIKeyRouteService.getCreatePath(), - body: JSON.stringify({ - name: apiKeyNameInput.value, - config_id: configId, - }), - }); - apiKeyNameInput.clear(); - setIsLoading(false); - onSuccess(res.data.item.id); - } catch (err) { - notifications.toasts.addError(err as Error, { - title: 'Error', - }); - setIsLoading(false); - } - }; - - return { - isLoading, - onSubmit, - apiKeyNameInput, - }; -} - -export const APIKeySelection: React.FunctionComponent = ({ onKeyChange, agentConfigs }) => { - const enrollmentAPIKeysRequest = useGetEnrollmentAPIKeys({ - page: 1, - perPage: 1000, - }); - - const [selectedState, setSelectedState] = useState<{ - agentConfigId?: string; - enrollmentAPIKeyId?: string; - }>({ - agentConfigId: agentConfigs.length ? agentConfigs[0].id : undefined, - }); - const filteredEnrollmentAPIKeys = React.useMemo(() => { - if (!selectedState.agentConfigId || !enrollmentAPIKeysRequest.data) { - return []; - } - - return enrollmentAPIKeysRequest.data.list.filter( - key => key.config_id === selectedState.agentConfigId - ); - }, [enrollmentAPIKeysRequest.data, selectedState.agentConfigId]); - - // Select first API key when config change - React.useEffect(() => { - if (!selectedState.enrollmentAPIKeyId && filteredEnrollmentAPIKeys.length > 0) { - const enrollmentAPIKeyId = filteredEnrollmentAPIKeys[0].id; - setSelectedState({ - agentConfigId: selectedState.agentConfigId, - enrollmentAPIKeyId, - }); - onKeyChange(enrollmentAPIKeyId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filteredEnrollmentAPIKeys, selectedState.enrollmentAPIKeyId, selectedState.agentConfigId]); - - const [showAPIKeyForm, setShowAPIKeyForm] = useState(false); - const apiKeyForm = useCreateApiKeyForm(selectedState.agentConfigId, async (keyId: string) => { - const res = await enrollmentAPIKeysRequest.sendRequest(); - setSelectedState({ - ...selectedState, - enrollmentAPIKeyId: res.data?.list.find(key => key.id === keyId)?.id, - }); - setShowAPIKeyForm(false); - }); - - return ( - <> - - - - - - - - } - > - ({ - value: agentConfig.id, - text: agentConfig.name, - }))} - value={selectedState.agentConfigId || undefined} - onChange={e => - setSelectedState({ - agentConfigId: e.target.value, - enrollmentAPIKeyId: undefined, - }) - } - /> - - - - - } - labelAppend={ - - setShowAPIKeyForm(!showAPIKeyForm)} color="primary"> - {showAPIKeyForm ? ( - - ) : ( - - )} - - - } - > - {showAPIKeyForm ? ( -
- - - ) : ( - ({ - value: key.id, - text: key.name, - }))} - value={selectedState.enrollmentAPIKeyId || undefined} - onChange={e => { - setSelectedState({ - ...selectedState, - enrollmentAPIKeyId: e.target.value, - }); - onKeyChange(selectedState.enrollmentAPIKeyId); - }} - /> - )} -
-
-
- - ); -}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts deleted file mode 100644 index c82c82db6f713..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts +++ /dev/null @@ -1,6 +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. - */ -export { AgentEnrollmentFlyout } from './agent_enrollment_flyout'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.scss b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.scss deleted file mode 100644 index 10e809c5f5566..0000000000000 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.scss +++ /dev/null @@ -1,6 +0,0 @@ -.fleet__agentList__table .euiTableFooterCell { - .euiTableCellContent, - .euiTableCellContent__text { - overflow: visible; - } -} \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 05264c157434e..829b0cb69e67b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -25,7 +25,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; import { CSSProperties } from 'styled-components'; -import { AgentEnrollmentFlyout } from './components'; +import { AgentEnrollmentFlyout } from '../components'; import { Agent } from '../../../types'; import { usePagination, @@ -238,13 +238,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const columns = [ { - field: 'local_metadata.host.hostname', + field: 'local_metadata.host', name: i18n.translate('xpack.ingestManager.agentList.hostColumnTitle', { defaultMessage: 'Host', }), render: (host: string, agent: Agent) => ( - {host} + {agent.local_metadata['host.hostname'] || host || ''} ), }, @@ -308,11 +308,13 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, }, { - field: 'local_metadata.agent_version', + field: 'local_metadata.version', width: '100px', name: i18n.translate('xpack.ingestManager.agentList.versionTitle', { defaultMessage: 'Version', }), + render: (version: string, agent: Agent) => + agent.local_metadata['agent.version'] || version || '', }, { field: 'last_checkin', diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_config_datasource_badges.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_config_datasource_badges.tsx new file mode 100644 index 0000000000000..30bc9dc701427 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_config_datasource_badges.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiText, EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; +import { Datasource } from '../../../types'; +import { useGetOneAgentConfig } from '../../../hooks'; +import { PackageIcon } from '../../../components/package_icon'; + +interface Props { + agentConfigId: string; +} + +export const AgentConfigDatasourceBadges: React.FunctionComponent = ({ agentConfigId }) => { + const agentConfigRequest = useGetOneAgentConfig(agentConfigId); + const agentConfig = agentConfigRequest.data ? agentConfigRequest.data.item : null; + + if (!agentConfig) { + return null; + } + return ( + <> + + {agentConfig.datasources.length}, + }} + /> + + + {(agentConfig.datasources as Datasource[]).map((datasource, idx) => { + if (!datasource.package) { + return null; + } + return ( + + + + + + {datasource.package.title} + + + ); + })} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx new file mode 100644 index 0000000000000..a8cebfdf899a6 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSelect, EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; +import { AgentConfig } from '../../../../types'; +import { useGetEnrollmentAPIKeys } from '../../../../hooks'; +import { AgentConfigDatasourceBadges } from '../agent_config_datasource_badges'; + +interface Props { + agentConfigs: AgentConfig[]; + onKeyChange: (key: string) => void; +} + +export const EnrollmentStepAgentConfig: React.FC = ({ agentConfigs, onKeyChange }) => { + const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState(false); + const enrollmentAPIKeysRequest = useGetEnrollmentAPIKeys({ + page: 1, + perPage: 1000, + }); + + const [selectedState, setSelectedState] = useState<{ + agentConfigId?: string; + enrollmentAPIKeyId?: string; + }>({ + agentConfigId: agentConfigs.length ? agentConfigs[0].id : undefined, + }); + const filteredEnrollmentAPIKeys = React.useMemo(() => { + if (!selectedState.agentConfigId || !enrollmentAPIKeysRequest.data) { + return []; + } + + return enrollmentAPIKeysRequest.data.list.filter( + key => key.config_id === selectedState.agentConfigId + ); + }, [enrollmentAPIKeysRequest.data, selectedState.agentConfigId]); + + // Select first API key when config change + React.useEffect(() => { + if (!selectedState.enrollmentAPIKeyId && filteredEnrollmentAPIKeys.length > 0) { + const enrollmentAPIKeyId = filteredEnrollmentAPIKeys[0].id; + setSelectedState({ + agentConfigId: selectedState.agentConfigId, + enrollmentAPIKeyId, + }); + onKeyChange(enrollmentAPIKeyId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredEnrollmentAPIKeys, selectedState.enrollmentAPIKeyId, selectedState.agentConfigId]); + + return ( + <> + + + + } + options={agentConfigs.map(config => ({ + value: config.id, + text: config.name, + }))} + value={selectedState.agentConfigId || undefined} + onChange={e => + setSelectedState({ + agentConfigId: e.target.value, + enrollmentAPIKeyId: undefined, + }) + } + aria-label={i18n.translate( + 'xpack.ingestManager.enrollmentStepAgentConfig.configSelectAriaLabel', + { defaultMessage: 'Agent configuration' } + )} + /> + + {selectedState.agentConfigId && ( + + )} + + setIsAuthenticationSettingsOpen(!isAuthenticationSettingsOpen)} + > + + + {isAuthenticationSettingsOpen && ( + <> + + ({ + value: key.id, + text: key.name, + }))} + value={selectedState.enrollmentAPIKeyId || undefined} + prepend={ + + + + } + onChange={e => { + setSelectedState({ + ...selectedState, + enrollmentAPIKeyId: e.target.value, + }); + onKeyChange(e.target.value); + }} + /> + + )} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx new file mode 100644 index 0000000000000..002b4772d9216 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx @@ -0,0 +1,131 @@ +/* + * 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 { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiFlyoutFooter, + EuiSteps, + EuiText, + EuiLink, +} from '@elastic/eui'; +import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentConfig } from '../../../../types'; +import { EnrollmentStepAgentConfig } from './config_selection'; +import { useGetOneEnrollmentAPIKey, useCore, useGetSettings } from '../../../../hooks'; +import { ManualInstructions } from '../../../../components/enrollment_instructions'; + +interface Props { + onClose: () => void; + agentConfigs: AgentConfig[]; +} + +export const AgentEnrollmentFlyout: React.FunctionComponent = ({ + onClose, + agentConfigs = [], +}) => { + const core = useCore(); + const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + + const settings = useGetSettings(); + const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); + + const kibanaUrl = + settings.data?.item?.kibana_url ?? `${window.location.origin}${core.http.basePath.get()}`; + const kibanaCASha256 = settings.data?.item?.kibana_ca_sha256; + + const steps: EuiContainedStepProps[] = [ + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepDownloadAgentTitle', { + defaultMessage: 'Download the Elastic Agent', + }), + children: ( + + + + + ), + }} + /> + + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepChooseAgentConfigTitle', { + defaultMessage: 'Choose an agent configuration', + }), + children: ( + + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepRunAgentTitle', { + defaultMessage: 'Enroll and run the Elastic Agent', + }), + children: apiKey.data && ( + + ), + }, + ]; + + return ( + + + +

+ +

+
+
+ + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx index 692c60cdce38c..2c103ade31f5b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_reassign_config_flyout/index.tsx @@ -19,17 +19,11 @@ import { EuiSelect, EuiFormRow, EuiText, - EuiBadge, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Datasource, Agent } from '../../../../types'; -import { - useGetOneAgentConfig, - sendPutAgentReassign, - useCore, - useGetAgentConfigs, -} from '../../../../hooks'; -import { PackageIcon } from '../../../../components/package_icon'; +import { Agent } from '../../../../types'; +import { sendPutAgentReassign, useCore, useGetAgentConfigs } from '../../../../hooks'; +import { AgentConfigDatasourceBadges } from '../agent_config_datasource_badges'; interface Props { onClose: () => void; @@ -45,9 +39,6 @@ export const AgentReassignConfigFlyout: React.FunctionComponent = ({ onCl const agentConfigsRequest = useGetAgentConfigs(); const agentConfigs = agentConfigsRequest.data ? agentConfigsRequest.data.items : []; - const agentConfigRequest = useGetOneAgentConfig(selectedAgentConfigId); - const agentConfig = agentConfigRequest.data ? agentConfigRequest.data.item : null; - const [isSubmitting, setIsSubmitting] = useState(false); async function onSubmit() { @@ -121,40 +112,9 @@ export const AgentReassignConfigFlyout: React.FunctionComponent = ({ onCl - {agentConfig && ( - - {agentConfig.datasources.length}, - }} - /> - + {selectedAgentConfigId && ( + )} - - {agentConfig && - (agentConfig.datasources as Datasource[]).map((datasource, idx) => { - if (!datasource.package) { - return null; - } - return ( - - - - - - {datasource.package.title} - - - ); - })} @@ -168,7 +128,7 @@ export const AgentReassignConfigFlyout: React.FunctionComponent = ({ onCl { 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} /> ); } @@ -46,7 +42,7 @@ export const FleetApp: React.FunctionComponent = () => { } /> - + 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 96d4d01d67a49..4d89268c14b28 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 0000000000000..0f6d3c5b55ce6 --- /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 0000000000000..b74cac9a62176 --- /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 0000000000000..7d1f0598a2767 --- /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 0000000000000..33db53e6fbff4 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.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 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 res = packagesRequest.data?.response; + const total = res?.length ?? 0; + const installed = res?.filter(p => p.status === InstallationStatus.installed)?.length ?? 0; + const updatablePackages = + res?.filter(item => 'savedObject' in item && item.version > item.savedObject.attributes.version) + ?.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 0000000000000..41d7a7a5f0bc3 --- /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 0000000000000..04de22c34fe6f --- /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 70d8e7d6882f8..87bd7ba62208b 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 { 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 { useGetAgentConfigs } from '../../hooks'; +import { AgentEnrollmentFlyout } from '../fleet/components'; +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; @@ -87,7 +51,6 @@ export const IngestManagerOverview: React.FunctionComponent = () => { defaultMessage="Ingest Manager" /> { )} - - -
- -

- -

-
- - - -
- - 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 d6483479a3f2f..ca5bf999aa81a 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, @@ -28,8 +30,8 @@ export { CreateAgentConfigResponse, UpdateAgentConfigRequest, UpdateAgentConfigResponse, - DeleteAgentConfigsRequest, - DeleteAgentConfigsResponse, + DeleteAgentConfigRequest, + DeleteAgentConfigResponse, // API schemas - Datasource CreateDatasourceRequest, CreateDatasourceResponse, @@ -90,4 +92,5 @@ export { DetailViewPanelName, InstallStatus, InstallationStatus, + Installable, } from '../../../../common'; diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 951ff2337d8c7..6096af8d80801 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 097825e0b69e1..d70e136d67ef5 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, + HttpServiceSetup, } 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,10 @@ export interface IngestManagerAppContext { security?: SecurityPluginSetup; config$?: Observable; savedObjects: SavedObjectsServiceStart; + isProductionMode: boolean; + kibanaVersion: string; + cloud?: CloudSetup; + httpSetup?: HttpServiceSetup; } export type IngestManagerSetupContract = void; @@ -100,16 +106,25 @@ export class IngestManagerPlugin private licensing$!: Observable; private config$: Observable; private security: SecurityPluginSetup | undefined; + private cloud: CloudSetup | undefined; + + private isProductionMode: boolean; + private kibanaVersion: string; + private httpSetup: HttpServiceSetup | undefined; constructor(private readonly initializerContext: PluginInitializerContext) { this.config$ = this.initializerContext.config.create(); + this.isProductionMode = this.initializerContext.env.mode.prod; + this.kibanaVersion = this.initializerContext.env.packageInfo.version; } public async setup(core: CoreSetup, deps: IngestManagerSetupDeps) { + this.httpSetup = core.http; this.licensing$ = deps.licensing.license$; if (deps.security) { this.security = deps.security; } + this.cloud = deps.cloud; registerSavedObjects(core.savedObjects); registerEncryptedSavedObjects(deps.encryptedSavedObjects); @@ -150,6 +165,7 @@ export class IngestManagerPlugin const config = await this.config$.pipe(first()).toPromise(); // Register routes + registerSetupRoutes(router, config); registerAgentConfigRoutes(router); registerDatasourceRoutes(router); registerOutputRoutes(router); @@ -162,12 +178,10 @@ export class IngestManagerPlugin } if (config.fleet.enabled) { - registerSetupRoutes(router); registerAgentRoutes(router); registerEnrollmentApiKeyRoutes(router); registerInstallScriptRoutes({ router, - serverInfo: core.http.getServerInfo(), basePath: core.http.basePath, }); } @@ -184,6 +198,10 @@ export class IngestManagerPlugin security: this.security, config$: this.config$, savedObjects: core.savedObjects, + isProductionMode: this.isProductionMode, + kibanaVersion: this.kibanaVersion, + httpSetup: this.httpSetup, + cloud: this.cloud, }); licenseService.start(this.licensing$); return { diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts index 69f14854cdd0f..023d465c9cda9 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts @@ -14,7 +14,7 @@ import { GetOneAgentConfigRequestSchema, CreateAgentConfigRequestSchema, UpdateAgentConfigRequestSchema, - DeleteAgentConfigsRequestSchema, + DeleteAgentConfigRequestSchema, GetFullAgentConfigRequestSchema, AgentConfig, DefaultPackages, @@ -26,7 +26,7 @@ import { GetOneAgentConfigResponse, CreateAgentConfigResponse, UpdateAgentConfigResponse, - DeleteAgentConfigsResponse, + DeleteAgentConfigResponse, GetFullAgentConfigResponse, } from '../../../common'; @@ -49,7 +49,7 @@ export const getAgentConfigsHandler: RequestHandler< items, (agentConfig: GetAgentConfigsResponseItem) => listAgents(soClient, { - showInactive: true, + showInactive: false, perPage: 0, page: 1, kuery: `${AGENT_SAVED_OBJECT_TYPE}.config_id:${agentConfig.id}`, @@ -179,13 +179,13 @@ export const updateAgentConfigHandler: RequestHandler< export const deleteAgentConfigsHandler: RequestHandler< unknown, unknown, - TypeOf + TypeOf > = async (context, request, response) => { const soClient = context.core.savedObjects.client; try { - const body: DeleteAgentConfigsResponse = await agentConfigService.delete( + const body: DeleteAgentConfigResponse = await agentConfigService.delete( soClient, - request.body.agentConfigIds + request.body.agentConfigId ); return response.ok({ body, diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts index b8e827974ff81..e630f3c959590 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts @@ -10,7 +10,7 @@ import { GetOneAgentConfigRequestSchema, CreateAgentConfigRequestSchema, UpdateAgentConfigRequestSchema, - DeleteAgentConfigsRequestSchema, + DeleteAgentConfigRequestSchema, GetFullAgentConfigRequestSchema, } from '../../types'; import { @@ -67,7 +67,7 @@ export const registerRoutes = (router: IRouter) => { router.post( { path: AGENT_CONFIG_API_ROUTES.DELETE_PATTERN, - validate: DeleteAgentConfigsRequestSchema, + validate: DeleteAgentConfigRequestSchema, options: { tags: [`access:${PLUGIN_ID}-all`] }, }, deleteAgentConfigsHandler diff --git a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts index a24518d644c4c..ad81076e34e4b 100644 --- a/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/data_streams/handlers.ts @@ -3,9 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { RequestHandler } from 'src/core/server'; +import { RequestHandler, SavedObjectsClientContract } from 'src/core/server'; import { DataStream } from '../../types'; -import { GetDataStreamsResponse } from '../../../common'; +import { GetDataStreamsResponse, KibanaAssetType } from '../../../common'; +import { getPackageSavedObjects, getKibanaSavedObject } from '../../services/epm/packages/get'; const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*'; @@ -21,11 +22,7 @@ export const getListHandler: RequestHandler = async (context, request, response) // Get all matching indices and info about each // This returns the top 100,000 indices (as buckets) by last activity - const { - aggregations: { - index: { buckets: indexResults }, - }, - } = await callCluster('search', { + const { aggregations } = await callCluster('search', { index: DATA_STREAM_INDEX_PATTERN, body: { size: 0, @@ -90,7 +87,24 @@ export const getListHandler: RequestHandler = async (context, request, response) }, }); - const dataStreams: DataStream[] = (indexResults as any[]).map(result => { + const body: GetDataStreamsResponse = { + data_streams: [], + }; + + if (!(aggregations && aggregations.index && aggregations.index.buckets)) { + return response.ok({ + body, + }); + } + + const { + index: { buckets: indexResults }, + } = aggregations; + + const packageSavedObjects = await getPackageSavedObjects(context.core.savedObjects.client); + const packageMetadata: any = {}; + + const dataStreamsPromises = (indexResults as any[]).map(async result => { const { key: indexName, dataset: { buckets: datasetBuckets }, @@ -99,20 +113,48 @@ export const getListHandler: RequestHandler = async (context, request, response) package: { buckets: packageBuckets }, last_activity: { value_as_string: lastActivity }, } = result; + + const pkg = packageBuckets.length ? packageBuckets[0].key : ''; + const pkgSavedObject = packageSavedObjects.saved_objects.filter(p => p.id === pkg); + + // if + // - the datastream is associated with a package + // - and the package has been installed through EPM + // - and we didn't pick the metadata in an earlier iteration of this map() + if (pkg !== '' && pkgSavedObject.length > 0 && !packageMetadata[pkg]) { + // then pick the dashboards from the package saved object + const dashboards = + pkgSavedObject[0].attributes?.installed?.filter( + o => o.type === KibanaAssetType.dashboard + ) || []; + // and then pick the human-readable titles from the dashboard saved objects + const enhancedDashboards = await getEnhancedDashboards( + context.core.savedObjects.client, + dashboards + ); + + packageMetadata[pkg] = { + version: pkgSavedObject[0].attributes?.version || '', + dashboards: enhancedDashboards, + }; + } return { index: indexName, dataset: datasetBuckets.length ? datasetBuckets[0].key : '', namespace: namespaceBuckets.length ? namespaceBuckets[0].key : '', type: typeBuckets.length ? typeBuckets[0].key : '', - package: packageBuckets.length ? packageBuckets[0].key : '', + package: pkg, + package_version: packageMetadata[pkg] ? packageMetadata[pkg].version : '', last_activity: lastActivity, size_in_bytes: indexStats[indexName] ? indexStats[indexName].total.store.size_in_bytes : 0, + dashboards: packageMetadata[pkg] ? packageMetadata[pkg].dashboards : [], }; }); - const body: GetDataStreamsResponse = { - data_streams: dataStreams, - }; + const dataStreams: DataStream[] = await Promise.all(dataStreamsPromises); + + body.data_streams = dataStreams; + return response.ok({ body, }); @@ -123,3 +165,21 @@ export const getListHandler: RequestHandler = async (context, request, response) }); } }; + +const getEnhancedDashboards = async ( + savedObjectsClient: SavedObjectsClientContract, + dashboards: any[] +) => { + const dashboardsPromises = dashboards.map(async db => { + const dbSavedObject: any = await getKibanaSavedObject( + savedObjectsClient, + KibanaAssetType.dashboard, + db.id + ); + return { + id: db.id, + title: dbSavedObject.attributes?.title || db.id, + }; + }); + return await Promise.all(dashboardsPromises); +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts index ad16e1dde456b..fd3a9c520b90a 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts @@ -136,6 +136,12 @@ export const installPackageHandler: RequestHandler; - serverInfo: HttpServerInfo; }) => { - const kibanaUrl = url.format({ - protocol: serverInfo.protocol, - hostname: serverInfo.host, - port: serverInfo.port, - pathname: basePath.serverBasePath, - }); - router.get( { path: INSTALL_SCRIPT_API_ROUTES, @@ -36,6 +34,19 @@ export const registerRoutes = ({ request: KibanaRequest<{ osType: 'macos' }>, response ) { + const soClient = getInternalUserSOClient(request); + const http = appContextService.getHttpSetup(); + const serverInfo = http.getServerInfo(); + const basePath = http.basePath; + const kibanaUrl = + (await settingsService.getSettings(soClient)).kibana_url || + url.format({ + protocol: serverInfo.protocol, + hostname: serverInfo.host, + port: serverInfo.port, + pathname: basePath.serverBasePath, + }); + const script = getScript(request.params.osType, kibanaUrl); return response.ok({ body: script }); 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 837e73b966feb..abe5f3620d214 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.getHttpSetup().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 edc9a0a268161..43dcf47d26c18 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts @@ -4,14 +4,16 @@ * 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'; -export const registerRoutes = (router: IRouter) => { +export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) => { // Ingest manager setup router.post( { @@ -23,6 +25,11 @@ export const registerRoutes = (router: IRouter) => { }, ingestManagerSetupHandler ); + + if (!config.fleet.enabled) { + return; + } + // Get Fleet setup router.get( { @@ -30,7 +37,7 @@ export const registerRoutes = (router: IRouter) => { 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 7ab6ef1920c18..84bcd7db3f7b1 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -6,7 +6,11 @@ import { uniq } from 'lodash'; import { SavedObjectsClientContract } from 'src/core/server'; import { AuthenticatedUser } from '../../../security/server'; -import { DEFAULT_AGENT_CONFIG, AGENT_CONFIG_SAVED_OBJECT_TYPE } from '../constants'; +import { + DEFAULT_AGENT_CONFIG, + AGENT_CONFIG_SAVED_OBJECT_TYPE, + AGENT_SAVED_OBJECT_TYPE, +} from '../constants'; import { Datasource, NewAgentConfig, @@ -15,7 +19,8 @@ import { AgentConfigStatus, ListWithKuery, } from '../types'; -import { DeleteAgentConfigsResponse, storedDatasourceToAgentDatasource } from '../../common'; +import { DeleteAgentConfigResponse, storedDatasourceToAgentDatasource } from '../../common'; +import { listAgents } from './agents'; import { datasourceService } from './datasource'; import { outputService } from './output'; import { agentConfigUpdateEventHandler } from './agent_config_update'; @@ -256,32 +261,40 @@ class AgentConfigService { public async delete( soClient: SavedObjectsClientContract, - ids: string[] - ): Promise { - const result: DeleteAgentConfigsResponse = []; - const defaultConfigId = await this.getDefaultAgentConfigId(soClient); + id: string + ): Promise { + const config = await this.get(soClient, id, false); + if (!config) { + throw new Error('Agent configuration not found'); + } - if (ids.includes(defaultConfigId)) { + const defaultConfigId = await this.getDefaultAgentConfigId(soClient); + if (id === defaultConfigId) { throw new Error('The default agent configuration cannot be deleted'); } - for (const id of ids) { - try { - await soClient.delete(SAVED_OBJECT_TYPE, id); - await this.triggerAgentConfigUpdatedEvent(soClient, 'deleted', id); - result.push({ - id, - success: true, - }); - } catch (e) { - result.push({ - id, - success: false, - }); - } + const { total } = await listAgents(soClient, { + showInactive: false, + perPage: 0, + page: 1, + kuery: `${AGENT_SAVED_OBJECT_TYPE}.config_id:${id}`, + }); + + if (total > 0) { + throw new Error('Cannot delete agent config that is assigned to agent(s)'); } - return result; + if (config.datasources && config.datasources.length) { + await datasourceService.delete(soClient, config.datasources as string[], { + skipUnassignFromAgentConfigs: true, + }); + } + await soClient.delete(SAVED_OBJECT_TYPE, id); + await this.triggerAgentConfigUpdatedEvent(soClient, 'deleted', id); + return { + id, + success: true, + }; } public async getFullConfig( @@ -301,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/agent_config_update.ts b/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts index 8c0e73201e1ff..1cca165906732 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts @@ -7,12 +7,19 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { generateEnrollmentAPIKey, deleteEnrollmentApiKeyForConfigId } from './api_keys'; import { updateAgentsForConfigId, unenrollForConfigId } from './agents'; +import { outputService } from './output'; export async function agentConfigUpdateEventHandler( soClient: SavedObjectsClientContract, action: string, configId: string ) { + const adminUser = await outputService.getAdminUser(soClient); + // If no admin user fleet is not enabled just skip this hook + if (!adminUser) { + return; + } + if (action === 'created') { await generateEnrollmentAPIKey(soClient, { configId, diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts index 9b1565e7d74aa..2c8b1d5bb6078 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts @@ -7,6 +7,7 @@ import { SavedObjectsClientContract, SavedObjectsBulkCreateObject } from 'src/core/server'; import { Agent, + NewAgentEvent, AgentEvent, AgentAction, AgentSOAttributes, @@ -23,7 +24,7 @@ import { appContextService } from '../app_context'; export async function agentCheckin( soClient: SavedObjectsClientContract, agent: Agent, - events: AgentEvent[], + events: NewAgentEvent[], localMetadata?: any ) { const updateData: { @@ -85,10 +86,10 @@ export async function agentCheckin( async function processEventsForCheckin( soClient: SavedObjectsClientContract, agent: Agent, - events: AgentEvent[] + events: NewAgentEvent[] ) { const acknowledgedActionIds: string[] = []; - const updatedErrorEvents = [...agent.current_error_events]; + const updatedErrorEvents: Array = [...agent.current_error_events]; for (const event of events) { // @ts-ignore event.config_id = agent.config_id; @@ -122,7 +123,7 @@ async function processEventsForCheckin( async function createEventsForAgent( soClient: SavedObjectsClientContract, agentId: string, - events: AgentEvent[] + events: NewAgentEvent[] ) { const objects: Array> = events.map( eventData => { @@ -139,11 +140,11 @@ async function createEventsForAgent( return soClient.bulkCreate(objects); } -function isErrorOrState(event: AgentEvent) { +function isErrorOrState(event: AgentEvent | NewAgentEvent) { return event.type === 'STATE' || event.type === 'ERROR'; } -function isActionEvent(event: AgentEvent) { +function isActionEvent(event: AgentEvent | NewAgentEvent) { return ( event.type === 'ACTION' && (event.subtype === 'ACKNOWLEDGED' || event.subtype === 'UNKNOWN') ); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/events.ts b/x-pack/plugins/ingest_manager/server/services/agents/events.ts index 2758374eba65f..947f79bbea094 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/events.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/events.ts @@ -39,6 +39,7 @@ export async function getAgentEvents( const items: AgentEvent[] = saved_objects.map(so => { return { + id: so.id, ...so.attributes, payload: so.attributes.payload ? JSON.parse(so.attributes.payload) : undefined, }; 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 e917d2edd1309..6da0a137fa087 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, HttpServiceSetup } 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,19 @@ class AppContextService { private config$?: Observable; private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; + private isProductionMode: boolean = false; + private kibanaVersion: string | undefined; + private cloud?: CloudSetup; + private httpSetup?: HttpServiceSetup; public async start(appContext: IngestManagerAppContext) { this.encryptedSavedObjects = appContext.encryptedSavedObjects; this.security = appContext.security; this.savedObjects = appContext.savedObjects; + this.isProductionMode = appContext.isProductionMode; + this.cloud = appContext.cloud; + this.kibanaVersion = appContext.kibanaVersion; + this.httpSetup = appContext.httpSetup; if (appContext.config$) { this.config$ = appContext.config$; @@ -41,9 +50,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 +74,24 @@ class AppContextService { } return this.savedObjects; } + + public getIsProductionMode() { + return this.isProductionMode; + } + + public getHttpSetup() { + if (!this.httpSetup) { + throw new Error('HttpServiceSetup not set.'); + } + return this.httpSetup; + } + + public getKibanaVersion() { + if (!this.kibanaVersion) { + throw new Error('Kibana version is not set.'); + } + return this.kibanaVersion; + } } 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 0a5ba43e40fba..0497bc5a2b541 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts @@ -145,7 +145,7 @@ class DatasourceService { public async delete( soClient: SavedObjectsClientContract, ids: string[], - options?: { user?: AuthenticatedUser } + options?: { user?: AuthenticatedUser; skipUnassignFromAgentConfigs?: boolean } ): Promise { const result: DeleteDatasourcesResponse = []; @@ -155,14 +155,16 @@ class DatasourceService { if (!oldDatasource) { throw new Error('Datasource not found'); } - await agentConfigService.unassignDatasources( - soClient, - oldDatasource.config_id, - [oldDatasource.id], - { - user: options?.user, - } - ); + if (!options?.skipUnassignFromAgentConfigs) { + await agentConfigService.unassignDatasources( + soClient, + oldDatasource.config_id, + [oldDatasource.id], + { + user: options?.user, + } + ); + } await soClient.delete(SAVED_OBJECT_TYPE, id); result.push({ id, @@ -194,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/agent/agent.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts index db2e4fe474640..a63feda504e95 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts @@ -17,9 +17,14 @@ describe('createStream', () => { exclude_files: [".gz$"] processors: - add_locale: ~ + password: {{password}} + {{#if password}} + hidden_password: {{password}} + {{/if}} `; const vars = { paths: { value: ['/usr/local/var/log/nginx/access.log'] }, + password: { type: 'password', value: '' }, }; const output = createStream(vars, streamTemplate); @@ -28,6 +33,7 @@ describe('createStream', () => { paths: ['/usr/local/var/log/nginx/access.log'], exclude_files: ['.gz$'], processors: [{ add_locale: null }], + password: '', }); }); @@ -36,6 +42,7 @@ describe('createStream', () => { input: redis/metrics metricsets: ["key"] test: null + password: {{password}} {{#if key.patterns}} key.patterns: {{key.patterns}} {{/if}} @@ -48,6 +55,7 @@ describe('createStream', () => { pattern: '*' `, }, + password: { type: 'password', value: '' }, }; const output = createStream(vars, streamTemplate); @@ -61,6 +69,7 @@ describe('createStream', () => { pattern: '*', }, ], + password: '', }); }); }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts index 8254c0d8aaa37..35411a2e95acf 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts @@ -71,5 +71,16 @@ export function createStream(variables: DatasourceConfigRecord, streamTemplate: const stream = template(vars); const yamlFromStream = safeLoad(stream, {}); - return replaceVariablesInYaml(yamlValues, yamlFromStream); + // Hack to keep empty string ('') values around in the end yaml because + // `safeLoad` replaces empty strings with null + const patchedYamlFromStream = Object.entries(yamlFromStream).reduce((acc, [key, value]) => { + if (value === null && typeof vars[key] === 'string' && vars[key].trim() === '') { + acc[key] = ''; + } else { + acc[key] = value; + } + return acc; + }, {} as { [k: string]: any }); + + return replaceVariablesInYaml(yamlValues, patchedYamlFromStream); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts index 25180244b0214..cacf84381dd88 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts @@ -93,7 +93,7 @@ test('tests processing text field with multi fields', () => { const fields: Field[] = safeLoad(textWithMultiFieldsLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(textWithMultiFieldsMapping)); + expect(mappings).toEqual(textWithMultiFieldsMapping); }); test('tests processing keyword field with multi fields', () => { @@ -127,7 +127,7 @@ test('tests processing keyword field with multi fields', () => { const fields: Field[] = safeLoad(keywordWithMultiFieldsLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(keywordWithMultiFieldsMapping)); + expect(mappings).toEqual(keywordWithMultiFieldsMapping); }); test('tests processing keyword field with multi fields with analyzed text field', () => { @@ -159,7 +159,7 @@ test('tests processing keyword field with multi fields with analyzed text field' const fields: Field[] = safeLoad(keywordWithAnalyzedMultiFieldsLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(keywordWithAnalyzedMultiFieldsMapping)); + expect(mappings).toEqual(keywordWithAnalyzedMultiFieldsMapping); }); test('tests processing object field with no other attributes', () => { @@ -177,7 +177,7 @@ test('tests processing object field with no other attributes', () => { const fields: Field[] = safeLoad(objectFieldLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldMapping)); + expect(mappings).toEqual(objectFieldMapping); }); test('tests processing object field with enabled set to false', () => { @@ -197,7 +197,7 @@ test('tests processing object field with enabled set to false', () => { const fields: Field[] = safeLoad(objectFieldEnabledFalseLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldEnabledFalseMapping)); + expect(mappings).toEqual(objectFieldEnabledFalseMapping); }); test('tests processing object field with dynamic set to false', () => { @@ -217,7 +217,7 @@ test('tests processing object field with dynamic set to false', () => { const fields: Field[] = safeLoad(objectFieldDynamicFalseLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldDynamicFalseMapping)); + expect(mappings).toEqual(objectFieldDynamicFalseMapping); }); test('tests processing object field with dynamic set to true', () => { @@ -237,7 +237,7 @@ test('tests processing object field with dynamic set to true', () => { const fields: Field[] = safeLoad(objectFieldDynamicTrueLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldDynamicTrueMapping)); + expect(mappings).toEqual(objectFieldDynamicTrueMapping); }); test('tests processing object field with dynamic set to strict', () => { @@ -257,7 +257,7 @@ test('tests processing object field with dynamic set to strict', () => { const fields: Field[] = safeLoad(objectFieldDynamicStrictLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldDynamicStrictMapping)); + expect(mappings).toEqual(objectFieldDynamicStrictMapping); }); test('tests processing object field with property', () => { @@ -282,7 +282,7 @@ test('tests processing object field with property', () => { const fields: Field[] = safeLoad(objectFieldWithPropertyLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldWithPropertyMapping)); + expect(mappings).toEqual(objectFieldWithPropertyMapping); }); test('tests processing object field with property, reverse order', () => { @@ -291,10 +291,12 @@ test('tests processing object field with property, reverse order', () => { type: keyword - name: a type: object + dynamic: false `; const objectFieldWithPropertyReversedMapping = { properties: { a: { + dynamic: false, properties: { b: { ignore_above: 1024, @@ -307,7 +309,91 @@ test('tests processing object field with property, reverse order', () => { const fields: Field[] = safeLoad(objectFieldWithPropertyReversedLiteralYml); const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - expect(JSON.stringify(mappings)).toEqual(JSON.stringify(objectFieldWithPropertyReversedMapping)); + expect(mappings).toEqual(objectFieldWithPropertyReversedMapping); +}); + +test('tests processing nested field with property', () => { + const nestedYaml = ` + - name: a.b + type: keyword + - name: a + type: nested + dynamic: false + `; + const expectedMapping = { + properties: { + a: { + dynamic: false, + type: 'nested', + properties: { + b: { + ignore_above: 1024, + type: 'keyword', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(nestedYaml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(expectedMapping); +}); + +test('tests processing nested field with property, nested field first', () => { + const nestedYaml = ` + - name: a + type: nested + include_in_parent: true + - name: a.b + type: keyword + `; + const expectedMapping = { + properties: { + a: { + include_in_parent: true, + type: 'nested', + properties: { + b: { + ignore_above: 1024, + type: 'keyword', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(nestedYaml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(expectedMapping); +}); + +test('tests processing nested leaf field with properties', () => { + const nestedYaml = ` + - name: a + type: object + dynamic: false + - name: a.b + type: nested + enabled: false + `; + const expectedMapping = { + properties: { + a: { + dynamic: false, + properties: { + b: { + enabled: false, + type: 'nested', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(nestedYaml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(expectedMapping); }); test('tests constant_keyword field type handling', () => { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts index 9736f6d1cbd3c..c45c7e706be58 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -71,7 +71,14 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings { switch (type) { case 'group': - fieldProps = generateMappings(field.fields!); + fieldProps = { ...generateMappings(field.fields!), ...generateDynamicAndEnabled(field) }; + break; + case 'group-nested': + fieldProps = { + ...generateMappings(field.fields!), + ...generateNestedProps(field), + type: 'nested', + }; break; case 'integer': fieldProps.type = 'long'; @@ -95,13 +102,10 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings { } break; case 'object': - fieldProps.type = 'object'; - if (field.hasOwnProperty('enabled')) { - fieldProps.enabled = field.enabled; - } - if (field.hasOwnProperty('dynamic')) { - fieldProps.dynamic = field.dynamic; - } + fieldProps = { ...fieldProps, ...generateDynamicAndEnabled(field), type: 'object' }; + break; + case 'nested': + fieldProps = { ...fieldProps, ...generateNestedProps(field), type: 'nested' }; break; case 'array': // this assumes array fields were validated in an earlier step @@ -128,6 +132,29 @@ export function generateMappings(fields: Field[]): IndexTemplateMappings { return { properties: props }; } +function generateDynamicAndEnabled(field: Field) { + const props: Properties = {}; + if (field.hasOwnProperty('enabled')) { + props.enabled = field.enabled; + } + if (field.hasOwnProperty('dynamic')) { + props.dynamic = field.dynamic; + } + return props; +} + +function generateNestedProps(field: Field) { + const props = generateDynamicAndEnabled(field); + + if (field.hasOwnProperty('include_in_parent')) { + props.include_in_parent = field.include_in_parent; + } + if (field.hasOwnProperty('include_in_root')) { + props.include_in_root = field.include_in_root; + } + return props; +} + function generateMultiFields(fields: Fields): MultiFields { const multiFields: MultiFields = {}; if (fields) { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts index 42989bb1e3ac9..f0ff4c6125452 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts @@ -210,4 +210,166 @@ describe('processFields', () => { JSON.stringify(objectFieldWithPropertyExpanded) ); }); + + test('correctly handles properties of object type fields where object comes second', () => { + const nested = [ + { + name: 'a.b', + type: 'keyword', + }, + { + name: 'a', + type: 'object', + dynamic: true, + }, + ]; + + const nestedExpanded = [ + { + name: 'a', + type: 'group', + dynamic: true, + fields: [ + { + name: 'b', + type: 'keyword', + }, + ], + }, + ]; + expect(processFields(nested)).toEqual(nestedExpanded); + }); + + test('correctly handles properties of nested type fields', () => { + const nested = [ + { + name: 'a', + type: 'nested', + dynamic: true, + }, + { + name: 'a.b', + type: 'keyword', + }, + ]; + + const nestedExpanded = [ + { + name: 'a', + type: 'group-nested', + dynamic: true, + fields: [ + { + name: 'b', + type: 'keyword', + }, + ], + }, + ]; + expect(processFields(nested)).toEqual(nestedExpanded); + }); + + test('correctly handles properties of nested type where nested top level comes second', () => { + const nested = [ + { + name: 'a.b', + type: 'keyword', + }, + { + name: 'a', + type: 'nested', + dynamic: true, + }, + ]; + + const nestedExpanded = [ + { + name: 'a', + type: 'group-nested', + dynamic: true, + fields: [ + { + name: 'b', + type: 'keyword', + }, + ], + }, + ]; + expect(processFields(nested)).toEqual(nestedExpanded); + }); + + test('ignores redefinitions of an object field', () => { + const object = [ + { + name: 'a', + type: 'object', + dynamic: true, + }, + { + name: 'a', + type: 'object', + dynamic: false, + }, + ]; + + const objectExpected = [ + { + name: 'a', + type: 'object', + // should preserve the field that was parsed first which had dynamic: true + dynamic: true, + }, + ]; + expect(processFields(object)).toEqual(objectExpected); + }); + + test('ignores redefinitions of a nested field', () => { + const nested = [ + { + name: 'a', + type: 'nested', + dynamic: true, + }, + { + name: 'a', + type: 'nested', + dynamic: false, + }, + ]; + + const nestedExpected = [ + { + name: 'a', + type: 'nested', + // should preserve the field that was parsed first which had dynamic: true + dynamic: true, + }, + ]; + expect(processFields(nested)).toEqual(nestedExpected); + }); + + test('ignores redefinitions of a nested and object field', () => { + const nested = [ + { + name: 'a', + type: 'nested', + dynamic: true, + }, + { + name: 'a', + type: 'object', + dynamic: false, + }, + ]; + + const nestedExpected = [ + { + name: 'a', + type: 'nested', + // should preserve the field that was parsed first which had dynamic: true + dynamic: true, + }, + ]; + expect(processFields(nested)).toEqual(nestedExpected); + }); }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts index edf7624d3f0d5..abaf7ab5b0dfc 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts @@ -28,6 +28,8 @@ export interface Field { object_type?: string; scaling_factor?: number; dynamic?: 'strict' | boolean; + include_in_parent?: boolean; + include_in_root?: boolean; // Kibana specific analyzed?: boolean; @@ -108,18 +110,54 @@ function dedupFields(fields: Fields): Fields { return f.name === field.name; }); if (found) { + // remove name, type, and fields from `field` variable so we avoid merging them into `found` + const { name, type, fields: nestedFields, ...importantFieldProps } = field; + /** + * There are a couple scenarios this if is trying to account for: + * Example 1 + * - name: a.b + * - name: a + * In this scenario found will be `group` and field could be either `object` or `nested` + * Example 2 + * - name: a + * - name: a.b + * In this scenario found could be `object` or `nested` and field will be group + */ if ( - (found.type === 'group' || found.type === 'object') && - field.type === 'group' && - field.fields + // only merge if found is a group and field is object, nested, or group. + // Or if found is object, or nested, and field is a group. + // This is to avoid merging two objects, or nested, or object with a nested. + (found.type === 'group' && + (field.type === 'object' || field.type === 'nested' || field.type === 'group')) || + ((found.type === 'object' || found.type === 'nested') && field.type === 'group') ) { - if (!found.fields) { - found.fields = []; + // if the new field has properties let's dedup and concat them with the already existing found variable in + // the array + if (field.fields) { + // if the found type was object or nested it won't have a fields array so let's initialize it + if (!found.fields) { + found.fields = []; + } + found.fields = dedupFields(found.fields.concat(field.fields)); } - found.type = 'group'; - found.fields = dedupFields(found.fields.concat(field.fields)); + + // if found already had fields or got new ones from the new field coming in we need to assign the right + // type to it + if (found.fields) { + // If this field is supposed to be `nested` and we have fields, we need to preserve the fact that it is + // supposed to be `nested` for when the template is actually generated + if (found.type === 'nested' || field.type === 'nested') { + found.type = 'group-nested'; + } else { + // found was either `group` already or `object` so just set it to `group` + found.type = 'group'; + } + } + // we need to merge in other properties (like `dynamic`) that might exist + Object.assign(found, importantFieldProps); + // if `field.type` wasn't group object or nested, then there's a conflict in types, so lets ignore it } else { - // only 'group' fields can be merged in this way + // only `group`, `object`, and `nested` fields can be merged in this way // XXX: don't abort on error for now // see discussion in https://github.com/elastic/kibana/pull/59894 // throw new Error( diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts index da8d79a04b97c..6db08e344b3da 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -6,7 +6,7 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; -import { Installation, InstallationStatus, PackageInfo } from '../../../types'; +import { Installation, InstallationStatus, PackageInfo, KibanaAssetType } from '../../../types'; import * as Registry from '../registry'; import { createInstallableFrom } from './index'; @@ -32,11 +32,10 @@ export async function getPackages( ); }); // get the installed packages - const results = await savedObjectsClient.find({ - type: PACKAGES_SAVED_OBJECT_TYPE, - }); + const packageSavedObjects = await getPackageSavedObjects(savedObjectsClient); + // filter out any internal packages - const savedObjectsVisible = results.saved_objects.filter(o => !o.attributes.internal); + const savedObjectsVisible = packageSavedObjects.saved_objects.filter(o => !o.attributes.internal); const packageList = registryItems .map(item => createInstallableFrom( @@ -48,6 +47,12 @@ export async function getPackages( return packageList; } +export async function getPackageSavedObjects(savedObjectsClient: SavedObjectsClientContract) { + return savedObjectsClient.find({ + type: PACKAGES_SAVED_OBJECT_TYPE, + }); +} + export async function getPackageKeysByStatus( savedObjectsClient: SavedObjectsClientContract, status: InstallationStatus @@ -114,3 +119,11 @@ function sortByName(a: { name: string }, b: { name: string }) { return 0; } } + +export async function getKibanaSavedObject( + savedObjectsClient: SavedObjectsClientContract, + type: KibanaAssetType, + id: string +) { + return savedObjectsClient.get(type, id); +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts index c67cccd044bf5..d49e0e661440f 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts @@ -43,7 +43,6 @@ export function createInstallableFrom( ? { ...from, status: InstallationStatus.installed, - installedVersion: savedObject.attributes.version, savedObject, } : { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 8f51c4d78305c..632bc3ac9b69f 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -5,6 +5,7 @@ */ import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import Boom from 'boom'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; import { AssetReference, @@ -93,11 +94,18 @@ export async function installPackage(options: { const { savedObjectsClient, pkgkey, callCluster } = options; // TODO: change epm API to /packageName/version so we don't need to do this const [pkgName, pkgVersion] = pkgkey.split('-'); + // see if some version of this package is already installed + // TODO: calls to getInstallationObject, Registry.fetchInfo, and Registry.fetchFindLatestPackge + // and be replaced by getPackageInfo after adjusting for it to not group/use archive assets const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); - const reinstall = pkgVersion === installedPkg?.attributes.version; - const registryPackageInfo = await Registry.fetchInfo(pkgName, pkgVersion); + const latestPackage = await Registry.fetchFindLatestPackage(pkgName); + + if (pkgVersion < latestPackage.version) + throw Boom.badRequest('Cannot install or update to an out-of-date package'); + + const reinstall = pkgVersion === installedPkg?.attributes.version; const { internal = false, removable = true } = registryPackageInfo; // delete the previous version's installation's SO kibana assets before installing new ones 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 befb4722b6504..23e63f0a89a5e 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 @@ -5,11 +5,13 @@ */ import { SavedObjectsClientContract } from 'src/core/server'; -import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; +import Boom from 'boom'; +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; @@ -20,11 +22,22 @@ export async function removeInstallation(options: { // TODO: the epm api should change to /name/version so we don't need to do this const [pkgName] = pkgkey.split('-'); const installation = await getInstallation({ savedObjectsClient, pkgName }); - if (!installation) throw new Error('integration does not exist'); + if (!installation) throw Boom.badRequest(`${pkgName} is not installed`); if (installation.removable === false) - throw new Error(`The ${pkgName} integration is installed by default and cannot be removed`); + 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/install_script/index.ts b/x-pack/plugins/ingest_manager/server/services/install_script/index.ts index 7e7f8d2a3734b..02386531f5d61 100644 --- a/x-pack/plugins/ingest_manager/server/services/install_script/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/install_script/index.ts @@ -4,14 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { appContextService } from '../app_context'; import { macosInstallTemplate } from './install_templates/macos'; +import { linuxInstallTemplate } from './install_templates/linux'; -export function getScript(osType: 'macos', kibanaUrl: string): string { - const variables = { kibanaUrl }; +export function getScript(osType: 'macos' | 'linux', kibanaUrl: string): string { + const variables = { kibanaUrl, kibanaVersion: appContextService.getKibanaVersion() }; switch (osType) { case 'macos': return macosInstallTemplate(variables); + case 'linux': + return linuxInstallTemplate(variables); default: throw new Error(`${osType} is not supported.`); } diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/linux.ts b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/linux.ts new file mode 100644 index 0000000000000..0bb68c40bc580 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/linux.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 { InstallTemplateFunction } from './types'; + +export const linuxInstallTemplate: InstallTemplateFunction = variables => { + const artifact = `elastic-agent-${variables.kibanaVersion}-linux-x86_64`; + + return `#!/bin/sh + +set -e +curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/${artifact}.tar.gz +tar -xzvf ${artifact}.tar.gz +cd ${artifact} +./elastic-agent enroll ${variables.kibanaUrl} $API_KEY --force +./elastic-agent run +`; +}; diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts index e59dc6174b40f..11bb58d184d33 100644 --- a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts +++ b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts @@ -4,12 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { resolve } from 'path'; import { InstallTemplateFunction } from './types'; -const PROJECT_ROOT = resolve(__dirname, '../../../../'); -export const macosInstallTemplate: InstallTemplateFunction = variables => `#!/bin/sh +export const macosInstallTemplate: InstallTemplateFunction = variables => { + const artifact = `elastic-agent-${variables.kibanaVersion}-darwin-x86_64`; -eval "node ${PROJECT_ROOT}/scripts/dev_agent --enrollmentApiKey=$API_KEY --kibanaUrl=${variables.kibanaUrl}" + return `#!/bin/sh +set -e +curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/${artifact}.tar.gz +tar -xzvf ${artifact}.tar.gz +cd ${artifact} +./elastic-agent enroll ${variables.kibanaUrl} $API_KEY --force +./elastic-agent run `; +}; diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts index a478beaa96cfc..65d57f8ac7dbf 100644 --- a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts +++ b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts @@ -4,4 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export type InstallTemplateFunction = (variables: { kibanaUrl: string }) => string; +export type InstallTemplateFunction = (variables: { + kibanaUrl: string; + kibanaVersion: string; +}) => string; diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts index 395c9af4a4ca2..3628c5bd9e183 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 390e240841611..22acce8d4a51c 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import url from 'url'; import uuid from 'uuid'; import { SavedObjectsClientContract } from 'src/core/server'; import { CallESAsCurrentUser } from '../types'; @@ -38,10 +39,21 @@ export async function setupIngestManager( agentConfigService.ensureDefaultAgentConfig(soClient), settingsService.getSettings(soClient).catch((e: any) => { if (e.isBoom && e.output.statusCode === 404) { + const http = appContextService.getHttpSetup(); + const serverInfo = http.getServerInfo(); + const basePath = http.basePath; + + const defaultKibanaUrl = url.format({ + protocol: serverInfo.protocol, + hostname: serverInfo.host, + port: serverInfo.port, + pathname: basePath.serverBasePath, + }); + return settingsService.saveSettings(soClient, { agent_auto_upgrade: true, package_auto_upgrade: true, - kibana_url: appContextService.getConfig()?.fleet?.kibana?.host, + kibana_url: appContextService.getConfig()?.fleet?.kibana?.host ?? defaultKibanaUrl, }); } @@ -109,7 +121,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/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx index 27ed1de849987..e8ae8146d4fa2 100644 --- a/x-pack/plugins/ingest_manager/server/types/index.tsx +++ b/x-pack/plugins/ingest_manager/server/types/index.tsx @@ -12,6 +12,7 @@ export { AgentSOAttributes, AgentStatus, AgentType, + NewAgentEvent, AgentEvent, AgentEventSOAttributes, AgentAction, diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts index f18846348432b..1b396db9b0c88 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -49,8 +49,13 @@ export const AckEventSchema = schema.object({ ...{ action_id: schema.string() }, }); +export const NewAgentEventSchema = schema.object({ + ...AgentEventBase, +}); + export const AgentEventSchema = schema.object({ ...AgentEventBase, + id: schema.string(), }); export const NewAgentActionSchema = schema.object({ diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index ac1679101312e..5526e889124f9 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -5,7 +5,12 @@ */ import { schema } from '@kbn/config-schema'; -import { AckEventSchema, AgentEventSchema, AgentTypeSchema, NewAgentActionSchema } from '../models'; +import { + AckEventSchema, + NewAgentEventSchema, + AgentTypeSchema, + NewAgentActionSchema, +} from '../models'; export const GetAgentsRequestSchema = { query: schema.object({ @@ -28,7 +33,7 @@ export const PostAgentCheckinRequestSchema = { }), body: schema.object({ local_metadata: schema.maybe(schema.recordOf(schema.string(), schema.any())), - events: schema.maybe(schema.arrayOf(AgentEventSchema)), + events: schema.maybe(schema.arrayOf(NewAgentEventSchema)), }), }; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts index 0d223f028fc88..ab97ddc0ba723 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts @@ -29,9 +29,9 @@ export const UpdateAgentConfigRequestSchema = { body: NewAgentConfigSchema, }; -export const DeleteAgentConfigsRequestSchema = { +export const DeleteAgentConfigRequestSchema = { body: schema.object({ - agentConfigIds: schema.arrayOf(schema.string()), + agentConfigId: schema.string(), }), }; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts index cf676129cce7a..f872efc006b76 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts @@ -8,6 +8,6 @@ import { schema } from '@kbn/config-schema'; export const InstallScriptRequestSchema = { params: schema.object({ - osType: schema.oneOf([schema.literal('macos')]), + osType: schema.oneOf([schema.literal('macos'), schema.literal('linux')]), }), }; diff --git a/x-pack/plugins/ingest_pipelines/README.md b/x-pack/plugins/ingest_pipelines/README.md new file mode 100644 index 0000000000000..a469511bdbbd2 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/README.md @@ -0,0 +1,24 @@ +# Ingest Node Pipelines UI + +## Summary +The `ingest_pipelines` plugin provides Kibana support for [Elasticsearch's ingest nodes](https://www.elastic.co/guide/en/elasticsearch/reference/master/ingest.html). Please refer to the Elasticsearch documentation for more details. + +This plugin allows Kibana to create, edit, clone and delete ingest node pipelines. It also provides support to simulate a pipeline. + +It requires a Basic license and the following cluster privileges: `manage_pipeline` and `cluster:monitor/nodes/info`. + +--- + +## Development + +A new app called Ingest Node Pipelines is registered in the Management section and follows a typical CRUD UI pattern. The client-side portion of this app lives in [public/application](public/application) and uses endpoints registered in [server/routes/api](server/routes/api). + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions on setting up your development environment. + +### Test coverage + +The app has the following test coverage: + +- Complete API integration tests +- Smoke-level functional test +- Client-integration tests diff --git a/x-pack/plugins/ingest_pipelines/common/constants.ts b/x-pack/plugins/ingest_pipelines/common/constants.ts new file mode 100644 index 0000000000000..edf681c276a84 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/common/constants.ts @@ -0,0 +1,18 @@ +/* + * 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 { LicenseType } from '../../licensing/common/types'; + +const basicLicense: LicenseType = 'basic'; + +export const PLUGIN_ID = 'ingest_pipelines'; + +export const PLUGIN_MIN_LICENSE_TYPE = basicLicense; + +export const BASE_PATH = '/management/elasticsearch/ingest_pipelines'; + +export const API_BASE_PATH = '/api/ingest_pipelines'; + +export const APP_CLUSTER_REQUIRED_PRIVILEGES = ['manage_pipeline', 'cluster:monitor/nodes/info']; diff --git a/x-pack/plugins/ingest_pipelines/common/lib/index.ts b/x-pack/plugins/ingest_pipelines/common/lib/index.ts new file mode 100644 index 0000000000000..a976f66bc7c40 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/common/lib/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 { deserializePipelines } from './pipeline_serialization'; diff --git a/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.test.ts b/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.test.ts new file mode 100644 index 0000000000000..65d6b6e30497f --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.test.ts @@ -0,0 +1,68 @@ +/* + * 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 { deserializePipelines } from './pipeline_serialization'; + +describe('pipeline_serialization', () => { + describe('deserializePipelines()', () => { + it('should deserialize pipelines', () => { + expect( + deserializePipelines({ + pipeline1: { + description: 'pipeline 1 description', + version: 1, + processors: [ + { + script: { + source: 'ctx._type = null', + }, + }, + ], + on_failure: [ + { + set: { + field: 'error.message', + value: '{{ failure_message }}', + }, + }, + ], + }, + pipeline2: { + description: 'pipeline2 description', + version: 1, + processors: [], + }, + }) + ).toEqual([ + { + name: 'pipeline1', + description: 'pipeline 1 description', + version: 1, + processors: [ + { + script: { + source: 'ctx._type = null', + }, + }, + ], + on_failure: [ + { + set: { + field: 'error.message', + value: '{{ failure_message }}', + }, + }, + ], + }, + { + name: 'pipeline2', + description: 'pipeline2 description', + version: 1, + processors: [], + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.ts b/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.ts new file mode 100644 index 0000000000000..572f655076015 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/common/lib/pipeline_serialization.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. + */ + +import { PipelinesByName, Pipeline } from '../types'; + +export function deserializePipelines(pipelinesByName: PipelinesByName): Pipeline[] { + const pipelineNames: string[] = Object.keys(pipelinesByName); + + const deserializedPipelines = pipelineNames.map((name: string) => { + return { + ...pipelinesByName[name], + name, + }; + }); + + return deserializedPipelines; +} diff --git a/x-pack/plugins/ingest_pipelines/common/types.ts b/x-pack/plugins/ingest_pipelines/common/types.ts new file mode 100644 index 0000000000000..8d77359a7c3c5 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/common/types.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. + */ + +interface Processor { + [key: string]: { + [key: string]: unknown; + }; +} + +export interface Pipeline { + name: string; + description: string; + version?: number; + processors: Processor[]; + on_failure?: Processor[]; +} + +export interface PipelinesByName { + [key: string]: { + description: string; + version?: number; + processors: Processor[]; + on_failure?: Processor[]; + }; +} diff --git a/x-pack/plugins/ingest_pipelines/kibana.json b/x-pack/plugins/ingest_pipelines/kibana.json new file mode 100644 index 0000000000000..ec02c5f80edf9 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/kibana.json @@ -0,0 +1,17 @@ +{ + "id": "ingestPipelines", + "version": "8.0.0", + "server": true, + "ui": true, + "requiredPlugins": [ + "licensing", + "management" + ], + "optionalPlugins": [ + "usageCollection" + ], + "configPath": [ + "xpack", + "ingest_pipelines" + ] +} diff --git a/x-pack/plugins/ingest_pipelines/public/application/app.tsx b/x-pack/plugins/ingest_pipelines/public/application/app.tsx new file mode 100644 index 0000000000000..ba7675b507596 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/app.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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiPageContent } from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; +import { HashRouter, Switch, Route } from 'react-router-dom'; + +import { BASE_PATH, APP_CLUSTER_REQUIRED_PRIVILEGES } from '../../common/constants'; + +import { + SectionError, + useAuthorizationContext, + WithPrivileges, + SectionLoading, + NotAuthorizedSection, +} from '../shared_imports'; + +import { PipelinesList, PipelinesCreate, PipelinesEdit, PipelinesClone } from './sections'; + +export const AppWithoutRouter = () => ( + + + + + + {/* Catch all */} + + +); + +export const App: FunctionComponent = () => { + const { apiError } = useAuthorizationContext(); + + if (apiError) { + return ( + + } + error={apiError} + /> + ); + } + + return ( + `cluster.${privilege}`)} + > + {({ isLoading, hasPrivileges, privilegesMissing }) => { + if (isLoading) { + return ( + + + + ); + } + + if (!hasPrivileges) { + return ( + + + } + message={ + + } + /> + + ); + } + + return ( + + + + ); + }} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/index.ts new file mode 100644 index 0000000000000..21a2ee30a84e1 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/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 { PipelineForm } from './pipeline_form'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/index.ts new file mode 100644 index 0000000000000..2b007a25667a1 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/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 { PipelineFormProvider as PipelineForm } from './pipeline_form_provider'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx new file mode 100644 index 0000000000000..9082196a48b39 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; + +import { useForm, Form, FormConfig } from '../../../shared_imports'; +import { Pipeline } from '../../../../common/types'; + +import { PipelineRequestFlyout } from './pipeline_request_flyout'; +import { PipelineTestFlyout } from './pipeline_test_flyout'; +import { PipelineFormFields } from './pipeline_form_fields'; +import { PipelineFormError } from './pipeline_form_error'; +import { pipelineFormSchema } from './schema'; + +export interface PipelineFormProps { + onSave: (pipeline: Pipeline) => void; + onCancel: () => void; + isSaving: boolean; + saveError: any; + defaultValue?: Pipeline; + isEditing?: boolean; +} + +export const PipelineForm: React.FunctionComponent = ({ + defaultValue = { + name: '', + description: '', + processors: '', + on_failure: '', + version: '', + }, + onSave, + isSaving, + saveError, + isEditing, + onCancel, +}) => { + const [isRequestVisible, setIsRequestVisible] = useState(false); + + const [isTestingPipeline, setIsTestingPipeline] = useState(false); + + const handleSave: FormConfig['onSubmit'] = (formData, isValid) => { + if (isValid) { + onSave(formData as Pipeline); + } + }; + + const handleTestPipelineClick = () => { + setIsTestingPipeline(true); + }; + + const { form } = useForm({ + schema: pipelineFormSchema, + defaultValue, + onSubmit: handleSave, + }); + + const saveButtonLabel = isSaving ? ( + + ) : isEditing ? ( + + ) : ( + + ); + + return ( + <> +
+ {/* Request error */} + {saveError && } + + {/* All form fields */} + + + {/* Form submission */} + + + + + + {saveButtonLabel} + + + + + + + + + + + setIsRequestVisible(prevIsRequestVisible => !prevIsRequestVisible)} + > + {isRequestVisible ? ( + + ) : ( + + )} + + + + + {/* ES request flyout */} + {isRequestVisible ? ( + setIsRequestVisible(prevIsRequestVisible => !prevIsRequestVisible)} + /> + ) : null} + + {/* Test pipeline flyout */} + {isTestingPipeline ? ( + { + setIsTestingPipeline(prevIsTestingPipeline => !prevIsTestingPipeline); + }} + /> + ) : null} + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_error.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_error.tsx new file mode 100644 index 0000000000000..ef0e2737df24d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_error.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiCallOut } from '@elastic/eui'; + +interface Props { + errorMessage: string; +} + +export const PipelineFormError: React.FunctionComponent = ({ errorMessage }) => { + return ( + <> + + } + color="danger" + iconType="alert" + data-test-subj="savePipelineError" + > +

{errorMessage}

+
+ + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx new file mode 100644 index 0000000000000..045afd52204fa --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -0,0 +1,223 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiSpacer, EuiSwitch, EuiLink } from '@elastic/eui'; + +import { + getUseField, + getFormRow, + Field, + JsonEditorField, + useKibana, +} from '../../../shared_imports'; + +interface Props { + hasVersion: boolean; + hasOnFailure: boolean; + isTestButtonDisabled: boolean; + onTestPipelineClick: () => void; + isEditing?: boolean; +} + +const UseField = getUseField({ component: Field }); +const FormRow = getFormRow({ titleTag: 'h3' }); + +export const PipelineFormFields: React.FunctionComponent = ({ + isEditing, + hasVersion, + hasOnFailure, + isTestButtonDisabled, + onTestPipelineClick, +}) => { + const { services } = useKibana(); + + const [isVersionVisible, setIsVersionVisible] = useState(hasVersion); + const [isOnFailureEditorVisible, setIsOnFailureEditorVisible] = useState(hasOnFailure); + + return ( + <> + {/* Name field with optional version field */} + } + description={ + <> + + + + } + checked={isVersionVisible} + onChange={e => setIsVersionVisible(e.target.checked)} + data-test-subj="versionToggle" + /> + + } + > + + + {isVersionVisible && ( + + )} + + + {/* Description field */} + + } + description={ + + } + > + + + + {/* Processors field */} + + } + description={ + <> + + {i18n.translate('xpack.ingestPipelines.form.processorsDocumentionLink', { + defaultMessage: 'Learn more.', + })} + + ), + }} + /> + + + + + + + + } + > + + + + {/* On-failure field */} + + } + description={ + <> + + {i18n.translate('xpack.ingestPipelines.form.onFailureDocumentionLink', { + defaultMessage: 'Learn more.', + })} + + ), + }} + /> + + + } + checked={isOnFailureEditorVisible} + onChange={e => setIsOnFailureEditorVisible(e.target.checked)} + data-test-subj="onFailureToggle" + /> + + } + > + {isOnFailureEditorVisible ? ( + + ) : ( + // requires children or a field + // For now, we return an empty
if the editor is not visible +
+ )} + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_provider.tsx new file mode 100644 index 0000000000000..57abea2309aa1 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_provider.tsx @@ -0,0 +1,18 @@ +/* + * 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 { PipelineForm as PipelineFormUI, PipelineFormProps } from './pipeline_form'; +import { TestConfigContextProvider } from './test_config_context'; + +export const PipelineFormProvider: React.FunctionComponent = passThroughProps => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/index.ts new file mode 100644 index 0000000000000..9476b65557c54 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/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 { PipelineRequestFlyoutProvider as PipelineRequestFlyout } from './pipeline_request_flyout_provider'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx new file mode 100644 index 0000000000000..58e86695808b1 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout.tsx @@ -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 React, { useRef } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiButtonEmpty, + EuiCodeBlock, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { Pipeline } from '../../../../../common/types'; + +interface Props { + pipeline: Pipeline; + closeFlyout: () => void; +} + +export const PipelineRequestFlyout: React.FunctionComponent = ({ + closeFlyout, + pipeline, +}) => { + const { name, ...pipelineBody } = pipeline; + const endpoint = `PUT _ingest/pipeline/${name || ''}`; + const payload = JSON.stringify(pipelineBody, null, 2); + const request = `${endpoint}\n${payload}`; + // Hack so that copied-to-clipboard value updates as content changes + // Related issue: https://github.com/elastic/eui/issues/3321 + const uuid = useRef(0); + uuid.current++; + + return ( + + + +

+ {name ? ( + + ) : ( + + )} +

+
+
+ + + +

+ +

+
+ + + + {request} + +
+ + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.tsx new file mode 100644 index 0000000000000..6dcedca6085af --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_request_flyout/pipeline_request_flyout_provider.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 React, { useState, useEffect } from 'react'; + +import { Pipeline } from '../../../../../common/types'; +import { useFormContext } from '../../../../shared_imports'; +import { PipelineRequestFlyout } from './pipeline_request_flyout'; + +export const PipelineRequestFlyoutProvider = ({ closeFlyout }: { closeFlyout: () => void }) => { + const form = useFormContext(); + const [formData, setFormData] = useState({} as Pipeline); + + useEffect(() => { + const subscription = form.subscribe(async ({ isValid, validate, data }) => { + const isFormValid = isValid ?? (await validate()); + if (isFormValid) { + setFormData(data.format() as Pipeline); + } + }); + + return subscription.unsubscribe; + }, [form]); + + return ; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/index.ts new file mode 100644 index 0000000000000..38bbc43b469a5 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/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 { PipelineTestFlyoutProvider as PipelineTestFlyout } from './pipeline_test_flyout_provider'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout.tsx new file mode 100644 index 0000000000000..16f39b2912c1d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout.tsx @@ -0,0 +1,203 @@ +/* + * 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, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiSpacer, + EuiTitle, + EuiCallOut, +} from '@elastic/eui'; + +import { useKibana } from '../../../../shared_imports'; +import { Pipeline } from '../../../../../common/types'; +import { Tabs, Tab, OutputTab, DocumentsTab } from './tabs'; +import { useTestConfigContext } from '../test_config_context'; + +export interface PipelineTestFlyoutProps { + closeFlyout: () => void; + pipeline: Pipeline; + isPipelineValid: boolean; +} + +export const PipelineTestFlyout: React.FunctionComponent = ({ + closeFlyout, + pipeline, + isPipelineValid, +}) => { + const { services } = useKibana(); + + const { testConfig } = useTestConfigContext(); + const { documents: cachedDocuments, verbose: cachedVerbose } = testConfig; + + const initialSelectedTab = cachedDocuments ? 'output' : 'documents'; + const [selectedTab, setSelectedTab] = useState(initialSelectedTab); + + const [shouldExecuteImmediately, setShouldExecuteImmediately] = useState(false); + const [isExecuting, setIsExecuting] = useState(false); + const [executeError, setExecuteError] = useState(null); + const [executeOutput, setExecuteOutput] = useState(undefined); + + const handleExecute = useCallback( + async (documents: object[], verbose?: boolean) => { + const { name: pipelineName, ...pipelineDefinition } = pipeline; + + setIsExecuting(true); + setExecuteError(null); + + const { error, data: output } = await services.api.simulatePipeline({ + documents, + verbose, + pipeline: pipelineDefinition, + }); + + setIsExecuting(false); + + if (error) { + setExecuteError(error); + return; + } + + setExecuteOutput(output); + + services.notifications.toasts.addSuccess( + i18n.translate('xpack.ingestPipelines.testPipelineFlyout.successNotificationText', { + defaultMessage: 'Pipeline executed', + }), + { + toastLifeTimeMs: 1000, + } + ); + + setSelectedTab('output'); + }, + [pipeline, services.api, services.notifications.toasts] + ); + + useEffect(() => { + if (cachedDocuments) { + setShouldExecuteImmediately(true); + } + // We only want to know on initial mount if there are cached documents + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // If the user has already tested the pipeline once, + // use the cached test config and automatically execute the pipeline + if (shouldExecuteImmediately && Object.entries(pipeline).length > 0) { + setShouldExecuteImmediately(false); + handleExecute(cachedDocuments!, cachedVerbose); + } + }, [ + pipeline, + handleExecute, + cachedDocuments, + cachedVerbose, + isExecuting, + shouldExecuteImmediately, + ]); + + let tabContent; + + if (selectedTab === 'output') { + tabContent = ( + + ); + } else { + // default to "documents" tab + tabContent = ( + + ); + } + + return ( + + + +

+ {pipeline.name ? ( + + ) : ( + + )} +

+
+
+ + + !executeOutput && tabId === 'output'} + /> + + + + {/* Execute error */} + {executeError ? ( + <> + + } + color="danger" + iconType="alert" + > +

{executeError.message}

+
+ + + ) : null} + + {/* Invalid pipeline error */} + {!isPipelineValid ? ( + <> + + } + color="danger" + iconType="alert" + /> + + + ) : null} + + {/* Documents or output tab content */} + {tabContent} +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.tsx new file mode 100644 index 0000000000000..351478394595a --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/pipeline_test_flyout_provider.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, { useState, useEffect } from 'react'; + +import { Pipeline } from '../../../../../common/types'; +import { useFormContext } from '../../../../shared_imports'; +import { PipelineTestFlyout, PipelineTestFlyoutProps } from './pipeline_test_flyout'; + +type Props = Omit; + +export const PipelineTestFlyoutProvider: React.FunctionComponent = ({ closeFlyout }) => { + const form = useFormContext(); + const [formData, setFormData] = useState({} as Pipeline); + const [isFormDataValid, setIsFormDataValid] = useState(false); + + useEffect(() => { + const subscription = form.subscribe(async ({ isValid, validate, data }) => { + const isFormValid = isValid ?? (await validate()); + if (isFormValid) { + setFormData(data.format() as Pipeline); + } + setIsFormDataValid(isFormValid); + }); + + return subscription.unsubscribe; + }, [form]); + + return ( + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/index.ts new file mode 100644 index 0000000000000..ea8fe2cd92350 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/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 { Tabs, Tab } from './pipeline_test_tabs'; + +export { DocumentsTab } from './tab_documents'; + +export { OutputTab } from './tab_output'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/pipeline_test_tabs.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/pipeline_test_tabs.tsx new file mode 100644 index 0000000000000..f720b80122702 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/pipeline_test_tabs.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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTab, EuiTabs } from '@elastic/eui'; + +export type Tab = 'documents' | 'output'; + +interface Props { + onTabChange: (tab: Tab) => void; + selectedTab: Tab; + getIsDisabled: (tab: Tab) => boolean; +} + +export const Tabs: React.FunctionComponent = ({ + onTabChange, + selectedTab, + getIsDisabled, +}) => { + const tabs: Array<{ + id: Tab; + name: React.ReactNode; + }> = [ + { + id: 'documents', + name: ( + + ), + }, + { + id: 'output', + name: ( + + ), + }, + ]; + + return ( + + {tabs.map(tab => ( + onTabChange(tab.id)} + isSelected={tab.id === selectedTab} + key={tab.id} + disabled={getIsDisabled(tab.id)} + data-test-subj={tab.id.toLowerCase() + '_tab'} + > + {tab.name} + + ))} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/schema.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/schema.tsx new file mode 100644 index 0000000000000..de9910344bd4b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/schema.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiCode } from '@elastic/eui'; + +import { FormSchema, fieldValidators, ValidationFuncArg } from '../../../../../shared_imports'; +import { parseJson, stringifyJson } from '../../../../lib'; + +const { emptyField, isJsonField } = fieldValidators; + +export const documentsSchema: FormSchema = { + documents: { + label: i18n.translate( + 'xpack.ingestPipelines.testPipelineFlyout.documentsForm.documentsFieldLabel', + { + defaultMessage: 'Documents', + } + ), + helpText: ( + + {JSON.stringify([ + { + _index: 'index', + _id: 'id', + _source: { + foo: 'bar', + }, + }, + ])} + + ), + }} + /> + ), + serializer: parseJson, + deserializer: stringifyJson, + validations: [ + { + validator: emptyField( + i18n.translate( + 'xpack.ingestPipelines.testPipelineFlyout.documentsForm.noDocumentsError', + { + defaultMessage: 'Documents are required.', + } + ) + ), + }, + { + validator: isJsonField( + i18n.translate( + 'xpack.ingestPipelines.testPipelineFlyout.documentsForm.documentsJsonError', + { + defaultMessage: 'The documents JSON is not valid.', + } + ) + ), + }, + { + validator: ({ value }: ValidationFuncArg) => { + const parsedJSON = JSON.parse(value); + + if (!parsedJSON.length) { + return { + message: i18n.translate( + 'xpack.ingestPipelines.testPipelineFlyout.documentsForm.oneDocumentRequiredError', + { + defaultMessage: 'At least one document is required.', + } + ), + }; + } + }, + }, + ], + }, +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/tab_documents.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/tab_documents.tsx new file mode 100644 index 0000000000000..97bf03dbdc068 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/tab_documents.tsx @@ -0,0 +1,152 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { EuiSpacer, EuiText, EuiButton, EuiHorizontalRule, EuiLink } from '@elastic/eui'; + +import { + getUseField, + Field, + JsonEditorField, + Form, + useForm, + FormConfig, + useKibana, +} from '../../../../../shared_imports'; + +import { documentsSchema } from './schema'; +import { useTestConfigContext, TestConfig } from '../../test_config_context'; + +const UseField = getUseField({ component: Field }); + +interface Props { + handleExecute: (documents: object[], verbose: boolean) => void; + isPipelineValid: boolean; + isExecuting: boolean; +} + +export const DocumentsTab: React.FunctionComponent = ({ + isPipelineValid, + handleExecute, + isExecuting, +}) => { + const { services } = useKibana(); + + const { setCurrentTestConfig, testConfig } = useTestConfigContext(); + const { verbose: cachedVerbose, documents: cachedDocuments } = testConfig; + + const executePipeline: FormConfig['onSubmit'] = (formData, isValid) => { + if (!isValid || !isPipelineValid) { + return; + } + + const { documents } = formData as TestConfig; + + // Update context + setCurrentTestConfig({ + ...testConfig, + documents, + }); + + handleExecute(documents!, cachedVerbose); + }; + + const { form } = useForm({ + schema: documentsSchema, + defaultValue: { + documents: cachedDocuments || '', + verbose: cachedVerbose || false, + }, + onSubmit: executePipeline, + }); + + return ( + <> + +

+ + {i18n.translate( + 'xpack.ingestPipelines.testPipelineFlyout.documentsTab.simulateDocumentionLink', + { + defaultMessage: 'Learn more.', + } + )} + + ), + }} + /> +

+
+ + + +
+ {/* Documents editor */} + + + + + +

+ +

+
+ + + + + {isExecuting ? ( + + ) : ( + + )} + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/tab_output.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/tab_output.tsx new file mode 100644 index 0000000000000..aa80f8c86ad8b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_test_flyout/tabs/tab_output.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiCodeBlock, + EuiSpacer, + EuiText, + EuiSwitch, + EuiLink, + EuiIcon, + EuiLoadingSpinner, + EuiIconTip, +} from '@elastic/eui'; +import { useTestConfigContext } from '../../test_config_context'; + +interface Props { + executeOutput?: { docs: object[] }; + handleExecute: (documents: object[], verbose: boolean) => void; + isExecuting: boolean; +} + +export const OutputTab: React.FunctionComponent = ({ + executeOutput, + handleExecute, + isExecuting, +}) => { + const { setCurrentTestConfig, testConfig } = useTestConfigContext(); + const { verbose: cachedVerbose, documents: cachedDocuments } = testConfig; + + const onEnableVerbose = (isVerboseEnabled: boolean) => { + setCurrentTestConfig({ + ...testConfig, + verbose: isVerboseEnabled, + }); + + handleExecute(cachedDocuments!, isVerboseEnabled); + }; + + let content: React.ReactNode | undefined; + + if (isExecuting) { + content = ; + } else if (executeOutput) { + content = ( + + {JSON.stringify(executeOutput, null, 2)} + + ); + } + + return ( + <> + +

+ handleExecute(cachedDocuments!, cachedVerbose)}> + {' '} + + + ), + }} + /> +

+
+ + + + + {' '} + + } + /> + + } + checked={cachedVerbose} + onChange={e => onEnableVerbose(e.target.checked)} + /> + + + + {content} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/schema.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/schema.tsx new file mode 100644 index 0000000000000..2e2689f41527a --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/schema.tsx @@ -0,0 +1,147 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode } from '@elastic/eui'; + +import { FormSchema, FIELD_TYPES, fieldValidators, fieldFormatters } from '../../../shared_imports'; +import { parseJson, stringifyJson } from '../../lib'; + +const { emptyField, isJsonField } = fieldValidators; +const { toInt } = fieldFormatters; + +export const pipelineFormSchema: FormSchema = { + name: { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.form.nameFieldLabel', { + defaultMessage: 'Name', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.form.pipelineNameRequiredError', { + defaultMessage: 'A pipeline name is required.', + }) + ), + }, + ], + }, + description: { + type: FIELD_TYPES.TEXTAREA, + label: i18n.translate('xpack.ingestPipelines.form.descriptionFieldLabel', { + defaultMessage: 'Description', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.form.pipelineDescriptionRequiredError', { + defaultMessage: 'A pipeline description is required.', + }) + ), + }, + ], + }, + processors: { + label: i18n.translate('xpack.ingestPipelines.form.processorsFieldLabel', { + defaultMessage: 'Processors', + }), + helpText: ( + + {JSON.stringify([ + { + set: { + field: 'foo', + value: 'bar', + }, + }, + ])} + + ), + }} + /> + ), + serializer: parseJson, + deserializer: stringifyJson, + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.form.processorsRequiredError', { + defaultMessage: 'Processors are required.', + }) + ), + }, + { + validator: isJsonField( + i18n.translate('xpack.ingestPipelines.form.processorsJsonError', { + defaultMessage: 'The processors JSON is not valid.', + }) + ), + }, + ], + }, + on_failure: { + label: i18n.translate('xpack.ingestPipelines.form.onFailureFieldLabel', { + defaultMessage: 'On-failure processors (optional)', + }), + helpText: ( + + {JSON.stringify([ + { + set: { + field: '_index', + value: 'failed-{{ _index }}', + }, + }, + ])} + + ), + }} + /> + ), + serializer: value => { + const result = parseJson(value); + // If an empty array was passed, strip out this value entirely. + if (!result.length) { + return undefined; + } + return result; + }, + deserializer: stringifyJson, + validations: [ + { + validator: validationArg => { + if (!validationArg.value) { + return; + } + return isJsonField( + i18n.translate('xpack.ingestPipelines.form.onFailureProcessorsJsonError', { + defaultMessage: 'The on-failure processors JSON is not valid.', + }) + )(validationArg); + }, + }, + ], + }, + version: { + type: FIELD_TYPES.NUMBER, + label: i18n.translate('xpack.ingestPipelines.form.versionFieldLabel', { + defaultMessage: 'Version (optional)', + }), + formatters: [toInt], + }, +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/test_config_context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/test_config_context.tsx new file mode 100644 index 0000000000000..6840ebef28796 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/test_config_context.tsx @@ -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 React, { useState, useCallback, useContext } from 'react'; + +export interface TestConfig { + documents?: object[] | undefined; + verbose: boolean; +} + +interface TestConfigContext { + testConfig: TestConfig; + setCurrentTestConfig: (config: TestConfig) => void; +} + +const TEST_CONFIG_DEFAULT_VALUE = { + testConfig: { + verbose: false, + }, + setCurrentTestConfig: () => {}, +}; + +const TestConfigContext = React.createContext(TEST_CONFIG_DEFAULT_VALUE); + +export const useTestConfigContext = () => { + const ctx = useContext(TestConfigContext); + if (!ctx) { + throw new Error( + '"useTestConfigContext" can only be called inside of TestConfigContext.Provider!' + ); + } + return ctx; +}; + +export const TestConfigContextProvider = ({ children }: { children: React.ReactNode }) => { + const [testConfig, setTestConfig] = useState({ + verbose: false, + }); + + const setCurrentTestConfig = useCallback((currentTestConfig: TestConfig): void => { + setTestConfig(currentTestConfig); + }, []); + + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts b/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts new file mode 100644 index 0000000000000..776d44c825670 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/constants/index.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. + */ + +// UI metric constants +export const UIM_APP_NAME = 'ingest_pipelines'; +export const UIM_PIPELINES_LIST_LOAD = 'pipelines_list_load'; +export const UIM_PIPELINE_CREATE = 'pipeline_create'; +export const UIM_PIPELINE_UPDATE = 'pipeline_update'; +export const UIM_PIPELINE_DELETE = 'pipeline_delete'; +export const UIM_PIPELINE_DELETE_MANY = 'pipeline_delete_many'; +export const UIM_PIPELINE_SIMULATE = 'pipeline_simulate'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/index.tsx b/x-pack/plugins/ingest_pipelines/public/application/index.tsx new file mode 100644 index 0000000000000..e43dba4689b44 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/index.tsx @@ -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 { HttpSetup } from 'kibana/public'; +import React, { ReactNode } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { NotificationsSetup } from 'kibana/public'; +import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; + +import { API_BASE_PATH } from '../../common/constants'; + +import { AuthorizationProvider } from '../shared_imports'; + +import { App } from './app'; +import { DocumentationService, UiMetricService, ApiService, BreadcrumbService } from './services'; + +export interface AppServices { + breadcrumbs: BreadcrumbService; + metric: UiMetricService; + documentation: DocumentationService; + api: ApiService; + notifications: NotificationsSetup; +} + +export interface CoreServices { + http: HttpSetup; +} + +export const renderApp = ( + element: HTMLElement, + I18nContext: ({ children }: { children: ReactNode }) => JSX.Element, + services: AppServices, + coreServices: CoreServices +) => { + render( + + + + + + + , + element + ); + + return () => { + unmountComponentAtNode(element); + }; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/lib/index.ts b/x-pack/plugins/ingest_pipelines/public/application/lib/index.ts new file mode 100644 index 0000000000000..1283033267a50 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/lib/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 { stringifyJson, parseJson } from './utils'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/lib/utils.test.ts b/x-pack/plugins/ingest_pipelines/public/application/lib/utils.test.ts new file mode 100644 index 0000000000000..e7eff3bd6ca33 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/lib/utils.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { stringifyJson, parseJson } from './utils'; + +describe('utils', () => { + describe('stringifyJson()', () => { + it('should stringify a valid JSON array', () => { + expect(stringifyJson([1, 2, 3])).toEqual(`[ + 1, + 2, + 3 +]`); + }); + + it('should return a stringified empty array if the value is not a valid JSON array', () => { + expect(stringifyJson({})).toEqual('[\n\n]'); + }); + }); + + describe('parseJson()', () => { + it('should parse a valid JSON string', () => { + expect(parseJson('[1,2,3]')).toEqual([1, 2, 3]); + expect(parseJson('[{"foo": "bar"}]')).toEqual([{ foo: 'bar' }]); + }); + + it('should convert valid JSON that is not an array to an array', () => { + expect(parseJson('{"foo": "bar"}')).toEqual([{ foo: 'bar' }]); + }); + + it('should return an empty array if invalid JSON string', () => { + expect(parseJson('{invalidJsonString}')).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/lib/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/lib/utils.ts new file mode 100644 index 0000000000000..fe4e9e65f4b9a --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/lib/utils.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const stringifyJson = (json: any): string => + Array.isArray(json) ? JSON.stringify(json, null, 2) : '[\n\n]'; + +export const parseJson = (jsonString: string): object[] => { + let parsedJSON: any; + + try { + parsedJSON = JSON.parse(jsonString); + + if (!Array.isArray(parsedJSON)) { + // Convert object to array + parsedJSON = [parsedJSON]; + } + } catch { + parsedJSON = []; + } + + return parsedJSON; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts new file mode 100644 index 0000000000000..e36f27cbf5f62 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { CoreSetup } from 'src/core/public'; +import { ManagementAppMountParams } from 'src/plugins/management/public'; + +import { documentationService, uiMetricService, apiService, breadcrumbService } from './services'; +import { renderApp } from '.'; + +export async function mountManagementSection( + { http, getStartServices, notifications }: CoreSetup, + params: ManagementAppMountParams +) { + const { element, setBreadcrumbs } = params; + const [coreStart] = await getStartServices(); + const { + docLinks, + i18n: { Context: I18nContext }, + } = coreStart; + + documentationService.setup(docLinks); + breadcrumbService.setup(setBreadcrumbs); + + const services = { + breadcrumbs: breadcrumbService, + metric: uiMetricService, + documentation: documentationService, + api: apiService, + notifications, + }; + + return renderApp(element, I18nContext, services, { http }); +} diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts new file mode 100644 index 0000000000000..b2925666c5768 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { PipelinesList } from './pipelines_list'; + +export { PipelinesCreate } from './pipelines_create'; + +export { PipelinesEdit } from './pipelines_edit'; + +export { PipelinesClone } from './pipelines_clone'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/index.ts new file mode 100644 index 0000000000000..614a3598d407d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/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 { PipelinesClone } from './pipelines_clone'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx new file mode 100644 index 0000000000000..b3b1217caf834 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_clone/pipelines_clone.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent, useEffect } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { SectionLoading, useKibana } from '../../../shared_imports'; + +import { PipelinesCreate } from '../pipelines_create'; + +export interface ParamProps { + sourceName: string; +} + +/** + * This section is a wrapper around the create section where we receive a pipeline name + * to load and set as the source pipeline for the {@link PipelinesCreate} form. + */ +export const PipelinesClone: FunctionComponent> = props => { + const { sourceName } = props.match.params; + const { services } = useKibana(); + + const { error, data: pipeline, isLoading, isInitialRequest } = services.api.useLoadPipeline( + decodeURIComponent(sourceName) + ); + + useEffect(() => { + if (error && !isLoading) { + services.notifications!.toasts.addError(error, { + title: i18n.translate('xpack.ingestPipelines.clone.loadSourcePipelineErrorTitle', { + defaultMessage: 'Cannot load {name}.', + values: { name: sourceName }, + }), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [error, isLoading]); + + if (isLoading && isInitialRequest) { + return ( + + + + ); + } else { + // We still show the create form even if we were not able to load the + // latest pipeline data. + const sourcePipeline = pipeline ? { ...pipeline, name: `${pipeline.name}-copy` } : undefined; + return ; + } +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/index.ts new file mode 100644 index 0000000000000..374defa869916 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/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 { PipelinesCreate } from './pipelines_create'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx new file mode 100644 index 0000000000000..34a362d596d92 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_create/pipelines_create.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, useEffect } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiPageBody, + EuiPageContent, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiSpacer, +} from '@elastic/eui'; + +import { BASE_PATH } from '../../../../common/constants'; +import { Pipeline } from '../../../../common/types'; +import { useKibana } from '../../../shared_imports'; +import { PipelineForm } from '../../components'; + +interface Props { + /** + * This value may be passed in to prepopulate the creation form + */ + sourcePipeline?: Pipeline; +} + +export const PipelinesCreate: React.FunctionComponent = ({ + history, + sourcePipeline, +}) => { + const { services } = useKibana(); + + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const onSave = async (pipeline: Pipeline) => { + setIsSaving(true); + setSaveError(null); + + const { error } = await services.api.createPipeline(pipeline); + + setIsSaving(false); + + if (error) { + setSaveError(error); + return; + } + + history.push(BASE_PATH + `?pipeline=${pipeline.name}`); + }; + + const onCancel = () => { + history.push(BASE_PATH); + }; + + useEffect(() => { + services.breadcrumbs.setBreadcrumbs('create'); + }, [services]); + + return ( + + + + + + +

+ +

+
+
+ + + + + + +
+
+ + + + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/index.ts new file mode 100644 index 0000000000000..26458d23fd6d8 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/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 { PipelinesEdit } from './pipelines_edit'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx new file mode 100644 index 0000000000000..99cd8d7eef97b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_edit/pipelines_edit.tsx @@ -0,0 +1,151 @@ +/* + * 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 { RouteComponentProps } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, +} from '@elastic/eui'; + +import { EuiCallOut } from '@elastic/eui'; +import { BASE_PATH } from '../../../../common/constants'; +import { Pipeline } from '../../../../common/types'; +import { useKibana, SectionLoading } from '../../../shared_imports'; +import { PipelineForm } from '../../components'; + +interface MatchParams { + name: string; +} + +export const PipelinesEdit: React.FunctionComponent> = ({ + match: { + params: { name }, + }, + history, +}) => { + const { services } = useKibana(); + + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const decodedPipelineName = decodeURI(decodeURIComponent(name)); + + const { error, data: pipeline, isLoading } = services.api.useLoadPipeline(decodedPipelineName); + + const onSave = async (updatedPipeline: Pipeline) => { + setIsSaving(true); + setSaveError(null); + + const { error: savePipelineError } = await services.api.updatePipeline(updatedPipeline); + + setIsSaving(false); + + if (savePipelineError) { + setSaveError(savePipelineError); + return; + } + + history.push(BASE_PATH + `?pipeline=${updatedPipeline.name}`); + }; + + const onCancel = () => { + history.push(BASE_PATH); + }; + + useEffect(() => { + services.breadcrumbs.setBreadcrumbs('edit'); + }, [services.breadcrumbs]); + + let content: React.ReactNode; + + if (isLoading) { + content = ( + + + + ); + } else if (error) { + content = ( + <> + + } + color="danger" + iconType="alert" + data-test-subj="fetchPipelineError" + > +

{error.message}

+
+ + + ); + } else if (pipeline) { + content = ( + + ); + } + + return ( + + + + + + +

+ +

+
+
+ + + + + + +
+
+ + + + {content} +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx new file mode 100644 index 0000000000000..c7736a6c19ba1 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx @@ -0,0 +1,125 @@ +/* + * 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 { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { useKibana } from '../../../shared_imports'; + +export const PipelineDeleteModal = ({ + pipelinesToDelete, + callback, +}: { + pipelinesToDelete: string[]; + callback: (data?: { hasDeletedPipelines: boolean }) => void; +}) => { + const { services } = useKibana(); + + const numPipelinesToDelete = pipelinesToDelete.length; + + const handleDeletePipelines = () => { + services.api + .deletePipelines(pipelinesToDelete) + .then(({ data: { itemsDeleted, errors }, error }) => { + const hasDeletedPipelines = itemsDeleted && itemsDeleted.length; + + if (hasDeletedPipelines) { + const successMessage = + itemsDeleted.length === 1 + ? i18n.translate( + 'xpack.ingestPipelines.deleteModal.successDeleteSingleNotificationMessageText', + { + defaultMessage: "Deleted pipeline '{pipelineName}'", + values: { pipelineName: pipelinesToDelete[0] }, + } + ) + : i18n.translate( + 'xpack.ingestPipelines.deleteModal.successDeleteMultipleNotificationMessageText', + { + defaultMessage: + 'Deleted {numSuccesses, plural, one {# pipeline} other {# pipelines}}', + values: { numSuccesses: itemsDeleted.length }, + } + ); + + callback({ hasDeletedPipelines }); + services.notifications.toasts.addSuccess(successMessage); + } + + if (error || errors?.length) { + const hasMultipleErrors = errors?.length > 1 || (error && pipelinesToDelete.length > 1); + const errorMessage = hasMultipleErrors + ? i18n.translate( + 'xpack.ingestPipelines.deleteModal.multipleErrorsNotificationMessageText', + { + defaultMessage: 'Error deleting {count} pipelines', + values: { + count: errors?.length || pipelinesToDelete.length, + }, + } + ) + : i18n.translate('xpack.ingestPipelines.deleteModal.errorNotificationMessageText', { + defaultMessage: "Error deleting pipeline '{name}'", + values: { name: (errors && errors[0].name) || pipelinesToDelete[0] }, + }); + services.notifications.toasts.addDanger(errorMessage); + } + }); + }; + + const handleOnCancel = () => { + callback(); + }; + + return ( + + + } + onCancel={handleOnCancel} + onConfirm={handleDeletePipelines} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + <> +

+ +

+ +
    + {pipelinesToDelete.map(name => ( +
  • {name}
  • + ))} +
+ +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_flyout.tsx new file mode 100644 index 0000000000000..98243a5149c0d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_flyout.tsx @@ -0,0 +1,210 @@ +/* + * 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, { FunctionComponent, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiTitle, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiIcon, + EuiPopover, + EuiContextMenu, + EuiButton, +} from '@elastic/eui'; + +import { Pipeline } from '../../../../common/types'; + +import { PipelineDetailsJsonBlock } from './details_json_block'; + +export interface Props { + pipeline: Pipeline; + onEditClick: (pipelineName: string) => void; + onCloneClick: (pipelineName: string) => void; + onDeleteClick: (pipelineName: string[]) => void; + onClose: () => void; +} + +export const PipelineDetailsFlyout: FunctionComponent = ({ + pipeline, + onClose, + onEditClick, + onCloneClick, + onDeleteClick, +}) => { + const [showPopover, setShowPopover] = useState(false); + const actionMenuItems = [ + /** + * Edit pipeline + */ + { + name: i18n.translate('xpack.ingestPipelines.list.pipelineDetails.editActionLabel', { + defaultMessage: 'Edit', + }), + icon: , + onClick: () => onEditClick(pipeline.name), + }, + /** + * Clone pipeline + */ + { + name: i18n.translate('xpack.ingestPipelines.list.pipelineDetails.cloneActionLabel', { + defaultMessage: 'Clone', + }), + icon: , + onClick: () => onCloneClick(pipeline.name), + }, + /** + * Delete pipeline + */ + { + name: i18n.translate('xpack.ingestPipelines.list.pipelineDetails.deleteActionLabel', { + defaultMessage: 'Delete', + }), + icon: , + onClick: () => onDeleteClick([pipeline.name]), + }, + ]; + + const managePipelineButton = ( + setShowPopover(previousBool => !previousBool)} + iconType="arrowUp" + iconSide="right" + fill + > + {i18n.translate('xpack.ingestPipelines.list.pipelineDetails.managePipelineButtonLabel', { + defaultMessage: 'Manage', + })} + + ); + + return ( + + + +

{pipeline.name}

+
+
+ + + + {/* Pipeline description */} + + {i18n.translate('xpack.ingestPipelines.list.pipelineDetails.descriptionTitle', { + defaultMessage: 'Description', + })} + + + {pipeline.description ?? ''} + + + {/* Pipeline version */} + {pipeline.version && ( + <> + + {i18n.translate('xpack.ingestPipelines.list.pipelineDetails.versionTitle', { + defaultMessage: 'Version', + })} + + + {String(pipeline.version)} + + + )} + + {/* Processors JSON */} + + {i18n.translate('xpack.ingestPipelines.list.pipelineDetails.processorsTitle', { + defaultMessage: 'Processors JSON', + })} + + + + + + {/* On Failure Processor JSON */} + {pipeline.on_failure?.length && ( + <> + + {i18n.translate( + 'xpack.ingestPipelines.list.pipelineDetails.failureProcessorsTitle', + { + defaultMessage: 'On failure processors JSON', + } + )} + + + + + + )} + + + + + + + + {i18n.translate('xpack.ingestPipelines.list.pipelineDetails.closeButtonLabel', { + defaultMessage: 'Close', + })} + + + + + setShowPopover(false)} + button={managePipelineButton} + panelPaddingSize="none" + withTitle + repositionOnScroll + > + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_json_block.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_json_block.tsx new file mode 100644 index 0000000000000..6c44336c7547d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/details_json_block.tsx @@ -0,0 +1,31 @@ +/* + * 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, { FunctionComponent, useRef } from 'react'; +import { EuiCodeBlock } from '@elastic/eui'; + +export interface Props { + json: Record; +} + +export const PipelineDetailsJsonBlock: FunctionComponent = ({ json }) => { + // Hack so copied-to-clipboard value updates as content changes + // Related issue: https://github.com/elastic/eui/issues/3321 + const uuid = useRef(0); + uuid.current++; + + return ( + 0 ? 300 : undefined} + isCopyable + key={uuid.current} + > + {JSON.stringify(json, null, 2)} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.tsx new file mode 100644 index 0000000000000..ef64fb33a6a55 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/empty_list.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 React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { BASE_PATH } from '../../../../common/constants'; + +export const EmptyList: FunctionComponent = () => ( + + {i18n.translate('xpack.ingestPipelines.list.table.emptyPromptTitle', { + defaultMessage: 'Start by creating a pipeline', + })} +

+ } + actions={ + + {i18n.translate('xpack.ingestPipelines.list.table.emptyPrompt.createButtonLabel', { + defaultMessage: 'Create a pipeline', + })} + + } + /> +); diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/index.ts new file mode 100644 index 0000000000000..a541e3bb85fd0 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/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 { PipelinesList } from './main'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx new file mode 100644 index 0000000000000..c90ac2714a95a --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx @@ -0,0 +1,207 @@ +/* + * 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 { RouteComponentProps } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Location } from 'history'; +import { parse } from 'query-string'; + +import { + EuiPageBody, + EuiPageContent, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiCallOut, +} from '@elastic/eui'; + +import { EuiSpacer, EuiText } from '@elastic/eui'; + +import { Pipeline } from '../../../../common/types'; +import { BASE_PATH } from '../../../../common/constants'; +import { useKibana, SectionLoading } from '../../../shared_imports'; +import { UIM_PIPELINES_LIST_LOAD } from '../../constants'; + +import { EmptyList } from './empty_list'; +import { PipelineTable } from './table'; +import { PipelineDetailsFlyout } from './details_flyout'; +import { PipelineNotFoundFlyout } from './not_found_flyout'; +import { PipelineDeleteModal } from './delete_modal'; + +const getPipelineNameFromLocation = (location: Location) => { + const { pipeline } = parse(location.search.substring(1)); + return pipeline; +}; + +export const PipelinesList: React.FunctionComponent = ({ + history, + location, +}) => { + const { services } = useKibana(); + const pipelineNameFromLocation = getPipelineNameFromLocation(location); + + const [selectedPipeline, setSelectedPipeline] = useState(undefined); + const [showFlyout, setShowFlyout] = useState(false); + + const [pipelinesToDelete, setPipelinesToDelete] = useState([]); + + const { data, isLoading, error, sendRequest } = services.api.useLoadPipelines(); + + // Track component loaded + useEffect(() => { + services.metric.trackUiMetric(UIM_PIPELINES_LIST_LOAD); + services.breadcrumbs.setBreadcrumbs('home'); + }, [services.metric, services.breadcrumbs]); + + useEffect(() => { + if (pipelineNameFromLocation && data?.length) { + const pipeline = data.find(p => p.name === pipelineNameFromLocation); + setSelectedPipeline(pipeline); + setShowFlyout(true); + } + }, [pipelineNameFromLocation, data]); + + const goToEditPipeline = (name: string) => { + history.push(`${BASE_PATH}/edit/${encodeURIComponent(name)}`); + }; + + const goToClonePipeline = (name: string) => { + history.push(`${BASE_PATH}/create/${encodeURIComponent(name)}`); + }; + + const goHome = () => { + setShowFlyout(false); + history.push(BASE_PATH); + }; + + let content: React.ReactNode; + + if (isLoading) { + content = ( + + + + ); + } else if (data?.length) { + content = ( + + ); + } else { + content = ; + } + + const renderFlyout = (): React.ReactNode => { + if (!showFlyout) { + return; + } + if (selectedPipeline) { + return ( + { + setSelectedPipeline(undefined); + goHome(); + }} + onEditClick={goToEditPipeline} + onCloneClick={goToClonePipeline} + onDeleteClick={setPipelinesToDelete} + /> + ); + } else { + // Somehow we triggered show pipeline details, but do not have a pipeline. + // We assume not found. + return ( + { + goHome(); + }} + pipelineName={pipelineNameFromLocation} + /> + ); + } + }; + + return ( + <> + + + + + +

+ +

+
+ + + + + +
+
+ + + + + + + + {/* Error call out for pipeline table */} + {error ? ( + + ) : ( + content + )} +
+
+ {renderFlyout()} + {pipelinesToDelete?.length > 0 ? ( + { + if (deleteResponse?.hasDeletedPipelines) { + // reload pipelines list + sendRequest(); + } + setPipelinesToDelete([]); + setSelectedPipeline(undefined); + }} + pipelinesToDelete={pipelinesToDelete} + /> + ) : null} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/not_found_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/not_found_flyout.tsx new file mode 100644 index 0000000000000..b967e54187ced --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/not_found_flyout.tsx @@ -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 React, { FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlyout, EuiFlyoutBody, EuiCallOut } from '@elastic/eui'; +import { EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; + +interface Props { + onClose: () => void; + pipelineName: string | string[] | null | undefined; +} + +export const PipelineNotFoundFlyout: FunctionComponent = ({ onClose, pipelineName }) => { + return ( + + + {pipelineName && ( + +

{pipelineName}

+
+ )} +
+ + + + } + color="danger" + iconType="alert" + /> + +
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.tsx new file mode 100644 index 0000000000000..c93285289ff39 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/table.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 React, { FunctionComponent, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiInMemoryTable, EuiLink, EuiButton, EuiInMemoryTableProps } from '@elastic/eui'; + +import { BASE_PATH } from '../../../../common/constants'; +import { Pipeline } from '../../../../common/types'; + +export interface Props { + pipelines: Pipeline[]; + onReloadClick: () => void; + onEditPipelineClick: (pipelineName: string) => void; + onClonePipelineClick: (pipelineName: string) => void; + onDeletePipelineClick: (pipelineName: string[]) => void; +} + +export const PipelineTable: FunctionComponent = ({ + pipelines, + onReloadClick, + onEditPipelineClick, + onClonePipelineClick, + onDeletePipelineClick, +}) => { + const [selection, setSelection] = useState([]); + + const tableProps: EuiInMemoryTableProps = { + itemId: 'name', + isSelectable: true, + sorting: { sort: { field: 'name', direction: 'asc' } }, + selection: { + onSelectionChange: setSelection, + }, + search: { + toolsLeft: + selection.length > 0 ? ( + onDeletePipelineClick(selection.map(pipeline => pipeline.name))} + color="danger" + > + + + ) : ( + undefined + ), + toolsRight: [ + + {i18n.translate('xpack.ingestPipelines.list.table.reloadButtonLabel', { + defaultMessage: 'Reload', + })} + , + + {i18n.translate('xpack.ingestPipelines.list.table.createPipelineButtonLabel', { + defaultMessage: 'Create a pipeline', + })} + , + ], + box: { + incremental: true, + }, + }, + pagination: { + initialPageSize: 10, + pageSizeOptions: [10, 20, 50], + }, + columns: [ + { + field: 'name', + name: i18n.translate('xpack.ingestPipelines.list.table.nameColumnTitle', { + defaultMessage: 'Name', + }), + sortable: true, + render: (name: string) => {name}, + }, + { + name: ( + + ), + actions: [ + { + isPrimary: true, + name: i18n.translate('xpack.ingestPipelines.list.table.editActionLabel', { + defaultMessage: 'Edit', + }), + description: i18n.translate('xpack.ingestPipelines.list.table.editActionDescription', { + defaultMessage: 'Edit this pipeline', + }), + type: 'icon', + icon: 'pencil', + onClick: ({ name }) => onEditPipelineClick(name), + }, + { + name: i18n.translate('xpack.ingestPipelines.list.table.cloneActionLabel', { + defaultMessage: 'Clone', + }), + description: i18n.translate('xpack.ingestPipelines.list.table.cloneActionDescription', { + defaultMessage: 'Clone this pipeline', + }), + type: 'icon', + icon: 'copy', + onClick: ({ name }) => onClonePipelineClick(name), + }, + { + isPrimary: true, + name: i18n.translate('xpack.ingestPipelines.list.table.deleteActionLabel', { + defaultMessage: 'Delete', + }), + description: i18n.translate( + 'xpack.ingestPipelines.list.table.deleteActionDescription', + { defaultMessage: 'Delete this pipeline' } + ), + type: 'icon', + icon: 'trash', + color: 'danger', + onClick: ({ name }) => onDeletePipelineClick([name]), + }, + ], + }, + ], + items: pipelines ?? [], + }; + + return ; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/api.ts b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts new file mode 100644 index 0000000000000..13eb96e78adae --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts @@ -0,0 +1,125 @@ +/* + * 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 { HttpSetup } from 'src/core/public'; + +import { Pipeline } from '../../../common/types'; +import { API_BASE_PATH } from '../../../common/constants'; +import { + UseRequestConfig, + SendRequestConfig, + SendRequestResponse, + sendRequest as _sendRequest, + useRequest as _useRequest, +} from '../../shared_imports'; +import { UiMetricService } from './ui_metric'; +import { + UIM_PIPELINE_CREATE, + UIM_PIPELINE_UPDATE, + UIM_PIPELINE_DELETE, + UIM_PIPELINE_DELETE_MANY, + UIM_PIPELINE_SIMULATE, +} from '../constants'; + +export class ApiService { + private client: HttpSetup | undefined; + private uiMetricService: UiMetricService | undefined; + + private useRequest(config: UseRequestConfig) { + if (!this.client) { + throw new Error('Api service has not be initialized.'); + } + return _useRequest(this.client, config); + } + + private sendRequest( + config: SendRequestConfig + ): Promise> { + if (!this.client) { + throw new Error('Api service has not be initialized.'); + } + return _sendRequest(this.client, config); + } + + private trackUiMetric(eventName: string) { + if (!this.uiMetricService) { + throw new Error('UI metric service has not be initialized.'); + } + return this.uiMetricService.trackUiMetric(eventName); + } + + public setup(httpClient: HttpSetup, uiMetricService: UiMetricService): void { + this.client = httpClient; + this.uiMetricService = uiMetricService; + } + + public useLoadPipelines() { + return this.useRequest({ + path: API_BASE_PATH, + method: 'get', + }); + } + + public useLoadPipeline(name: string) { + return this.useRequest({ + path: `${API_BASE_PATH}/${encodeURIComponent(name)}`, + method: 'get', + }); + } + + public async createPipeline(pipeline: Pipeline) { + const result = await this.sendRequest({ + path: API_BASE_PATH, + method: 'post', + body: JSON.stringify(pipeline), + }); + + this.trackUiMetric(UIM_PIPELINE_CREATE); + + return result; + } + + public async updatePipeline(pipeline: Pipeline) { + const { name, ...body } = pipeline; + const result = await this.sendRequest({ + path: `${API_BASE_PATH}/${encodeURIComponent(name)}`, + method: 'put', + body: JSON.stringify(body), + }); + + this.trackUiMetric(UIM_PIPELINE_UPDATE); + + return result; + } + + public async deletePipelines(names: string[]) { + const result = this.sendRequest({ + path: `${API_BASE_PATH}/${names.map(name => encodeURIComponent(name)).join(',')}`, + method: 'delete', + }); + + this.trackUiMetric(names.length > 1 ? UIM_PIPELINE_DELETE_MANY : UIM_PIPELINE_DELETE); + + return result; + } + + public async simulatePipeline(testConfig: { + documents: object[]; + verbose?: boolean; + pipeline: Omit; + }) { + const result = await this.sendRequest({ + path: `${API_BASE_PATH}/simulate`, + method: 'post', + body: JSON.stringify(testConfig), + }); + + this.trackUiMetric(UIM_PIPELINE_SIMULATE); + + return result; + } +} + +export const apiService = new ApiService(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts b/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts new file mode 100644 index 0000000000000..1ccdbbad9b1bb --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts @@ -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 { i18n } from '@kbn/i18n'; +import { BASE_PATH } from '../../../common/constants'; +import { ManagementAppMountParams } from '../../../../../../src/plugins/management/public'; + +type SetBreadcrumbs = ManagementAppMountParams['setBreadcrumbs']; + +const homeBreadcrumbText = i18n.translate('xpack.ingestPipelines.breadcrumb.pipelinesLabel', { + defaultMessage: 'Ingest Node Pipelines', +}); + +export class BreadcrumbService { + private breadcrumbs: { + [key: string]: Array<{ + text: string; + href?: string; + }>; + } = { + home: [ + { + text: homeBreadcrumbText, + }, + ], + create: [ + { + text: homeBreadcrumbText, + href: `#${BASE_PATH}`, + }, + { + text: i18n.translate('xpack.ingestPipelines.breadcrumb.createPipelineLabel', { + defaultMessage: 'Create pipeline', + }), + }, + ], + edit: [ + { + text: homeBreadcrumbText, + href: `#${BASE_PATH}`, + }, + { + text: i18n.translate('xpack.ingestPipelines.breadcrumb.editPipelineLabel', { + defaultMessage: 'Edit pipeline', + }), + }, + ], + }; + + private setBreadcrumbsHandler?: SetBreadcrumbs; + + public setup(setBreadcrumbsHandler: SetBreadcrumbs): void { + this.setBreadcrumbsHandler = setBreadcrumbsHandler; + } + + public setBreadcrumbs(type: 'create' | 'home' | 'edit'): void { + if (!this.setBreadcrumbsHandler) { + throw new Error('Breadcrumb service has not been initialized'); + } + + const newBreadcrumbs = this.breadcrumbs[type] + ? [...this.breadcrumbs[type]] + : [...this.breadcrumbs.home]; + + this.setBreadcrumbsHandler(newBreadcrumbs); + } +} + +export const breadcrumbService = new BreadcrumbService(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts b/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts new file mode 100644 index 0000000000000..05fdc4b1dfb84 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts @@ -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 { DocLinksStart } from 'src/core/public'; + +export class DocumentationService { + private esDocBasePath: string = ''; + + public setup(docLinks: DocLinksStart): void { + const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; + const docsBase = `${ELASTIC_WEBSITE_URL}guide/en`; + + this.esDocBasePath = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; + } + + public getIngestNodeUrl() { + return `${this.esDocBasePath}/ingest.html`; + } + + public getProcessorsUrl() { + return `${this.esDocBasePath}/ingest-processors.html`; + } + + public getHandlingFailureUrl() { + return `${this.esDocBasePath}/handling-failure-in-pipelines.html`; + } + + public getPutPipelineApiUrl() { + return `${this.esDocBasePath}/put-pipeline-api.html`; + } + + public getSimulatePipelineApiUrl() { + return `${this.esDocBasePath}/simulate-pipeline-api.html`; + } +} + +export const documentationService = new DocumentationService(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/index.ts b/x-pack/plugins/ingest_pipelines/public/application/services/index.ts new file mode 100644 index 0000000000000..f03a7824f8364 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/services/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { documentationService, DocumentationService } from './documentation'; + +export { uiMetricService, UiMetricService } from './ui_metric'; + +export { apiService, ApiService } from './api'; + +export { breadcrumbService, BreadcrumbService } from './breadcrumbs'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/ui_metric.ts b/x-pack/plugins/ingest_pipelines/public/application/services/ui_metric.ts new file mode 100644 index 0000000000000..f99bb9ba331d2 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/services/ui_metric.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; + +import { UIM_APP_NAME } from '../constants'; + +export class UiMetricService { + private usageCollection: UsageCollectionSetup | undefined; + + public setup(usageCollection: UsageCollectionSetup) { + this.usageCollection = usageCollection; + } + + private track(name: string) { + if (!this.usageCollection) { + // Usage collection is an optional plugin and might be disabled + return; + } + + const { reportUiStats, METRIC_TYPE } = this.usageCollection; + reportUiStats(UIM_APP_NAME, METRIC_TYPE.COUNT, name); + } + + public trackUiMetric(eventName: string) { + return this.track(eventName); + } +} + +export const uiMetricService = new UiMetricService(); diff --git a/x-pack/plugins/ingest_pipelines/public/index.ts b/x-pack/plugins/ingest_pipelines/public/index.ts new file mode 100644 index 0000000000000..7247973703804 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/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. + */ + +import { IngestPipelinesPlugin } from './plugin'; + +export function plugin() { + return new IngestPipelinesPlugin(); +} diff --git a/x-pack/plugins/ingest_pipelines/public/plugin.ts b/x-pack/plugins/ingest_pipelines/public/plugin.ts new file mode 100644 index 0000000000000..0ab46f386e83b --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/plugin.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, Plugin } from 'src/core/public'; + +import { PLUGIN_ID } from '../common/constants'; +import { uiMetricService, apiService } from './application/services'; +import { Dependencies } from './types'; + +export class IngestPipelinesPlugin implements Plugin { + public setup(coreSetup: CoreSetup, plugins: Dependencies): void { + const { management, usageCollection } = plugins; + const { http } = coreSetup; + + // Initialize services + uiMetricService.setup(usageCollection); + apiService.setup(http, uiMetricService); + + management.sections.getSection('elasticsearch')!.registerApp({ + id: PLUGIN_ID, + order: 1, + title: i18n.translate('xpack.ingestPipelines.appTitle', { + defaultMessage: 'Ingest Node Pipelines', + }), + mount: async params => { + const { mountManagementSection } = await import('./application/mount_management_section'); + + return await mountManagementSection(coreSetup, params); + }, + }); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts new file mode 100644 index 0000000000000..cfa946ff942ec --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useKibana as _useKibana } from '../../../../src/plugins/kibana_react/public'; +import { AppServices } from './application'; + +export { + SendRequestConfig, + SendRequestResponse, + UseRequestConfig, + sendRequest, + useRequest, +} from '../../../../src/plugins/es_ui_shared/public/request/np_ready_request'; + +export { + FormSchema, + FIELD_TYPES, + FormConfig, + useForm, + Form, + getUseField, + ValidationFuncArg, + useFormContext, +} from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; + +export { + fieldFormatters, + fieldValidators, +} from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; + +export { + getFormRow, + Field, + JsonEditorField, +} from '../../../../src/plugins/es_ui_shared/static/forms/components'; + +export { + isJSON, + isEmptyString, +} from '../../../../src/plugins/es_ui_shared/static/validators/string'; + +export { + SectionLoading, + WithPrivileges, + AuthorizationProvider, + SectionError, + Error, + useAuthorizationContext, + NotAuthorizedSection, +} from '../../../../src/plugins/es_ui_shared/public'; + +export const useKibana = () => _useKibana(); diff --git a/x-pack/plugins/ingest_pipelines/public/types.ts b/x-pack/plugins/ingest_pipelines/public/types.ts new file mode 100644 index 0000000000000..91783ea04fa9a --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/types.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ManagementSetup } from 'src/plugins/management/public'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; + +export interface Dependencies { + management: ManagementSetup; + usageCollection: UsageCollectionSetup; +} diff --git a/x-pack/plugins/ingest_pipelines/server/index.ts b/x-pack/plugins/ingest_pipelines/server/index.ts new file mode 100644 index 0000000000000..dc162a5d67cb6 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/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. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { IngestPipelinesPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new IngestPipelinesPlugin(initializerContext); +} diff --git a/x-pack/plugins/ingest_pipelines/server/lib/index.ts b/x-pack/plugins/ingest_pipelines/server/lib/index.ts new file mode 100644 index 0000000000000..a9a3c61472d8c --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/lib/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 { isEsError } from './is_es_error'; diff --git a/x-pack/plugins/ingest_pipelines/server/lib/is_es_error.ts b/x-pack/plugins/ingest_pipelines/server/lib/is_es_error.ts new file mode 100644 index 0000000000000..4137293cf39c0 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/lib/is_es_error.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as legacyElasticsearch from 'elasticsearch'; + +const esErrorsParent = legacyElasticsearch.errors._Abstract; + +export function isEsError(err: Error) { + return err instanceof esErrorsParent; +} diff --git a/x-pack/plugins/ingest_pipelines/server/plugin.ts b/x-pack/plugins/ingest_pipelines/server/plugin.ts new file mode 100644 index 0000000000000..b27ca417c3e3c --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/plugin.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +import { PluginInitializerContext, CoreSetup, Plugin, Logger } from 'kibana/server'; + +import { PLUGIN_ID, PLUGIN_MIN_LICENSE_TYPE } from '../common/constants'; + +import { License } from './services'; +import { ApiRoutes } from './routes'; +import { isEsError } from './lib'; +import { Dependencies } from './types'; + +export class IngestPipelinesPlugin implements Plugin { + private readonly logger: Logger; + private readonly license: License; + private readonly apiRoutes: ApiRoutes; + + constructor({ logger }: PluginInitializerContext) { + this.logger = logger.get(); + this.license = new License(); + this.apiRoutes = new ApiRoutes(); + } + + public setup({ http, elasticsearch }: CoreSetup, { licensing }: Dependencies) { + this.logger.debug('ingest_pipelines: setup'); + + const router = http.createRouter(); + + this.license.setup( + { + pluginId: PLUGIN_ID, + minimumLicenseType: PLUGIN_MIN_LICENSE_TYPE, + defaultErrorMessage: i18n.translate('xpack.ingestPipelines.licenseCheckErrorMessage', { + defaultMessage: 'License check failed', + }), + }, + { + licensing, + logger: this.logger, + } + ); + + this.apiRoutes.setup({ + router, + license: this.license, + lib: { + isEsError, + }, + }); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/create.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/create.ts new file mode 100644 index 0000000000000..63637eaac765d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/create.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; + +import { Pipeline } from '../../../common/types'; +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; + +const bodySchema = schema.object({ + name: schema.string(), + description: schema.string(), + processors: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), + version: schema.maybe(schema.number()), + on_failure: schema.maybe(schema.arrayOf(schema.recordOf(schema.string(), schema.any()))), +}); + +export const registerCreateRoute = ({ + router, + license, + lib: { isEsError }, +}: RouteDependencies): void => { + router.post( + { + path: API_BASE_PATH, + validate: { + body: bodySchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.dataClient; + const pipeline = req.body as Pipeline; + + const { name, description, processors, version, on_failure } = pipeline; + + try { + // Check that a pipeline with the same name doesn't already exist + const pipelineByName = await callAsCurrentUser('ingest.getPipeline', { id: name }); + + if (pipelineByName[name]) { + return res.conflict({ + body: new Error( + i18n.translate('xpack.ingestPipelines.createRoute.duplicatePipelineIdErrorMessage', { + defaultMessage: "There is already a pipeline with name '{name}'.", + values: { + name, + }, + }) + ), + }); + } + } catch (e) { + // Silently swallow error + } + + try { + const response = await callAsCurrentUser('ingest.putPipeline', { + id: name, + body: { + description, + processors, + version, + on_failure, + }, + }); + + return res.ok({ body: response }); + } catch (error) { + if (isEsError(error)) { + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + + return res.internalError({ body: error }); + } + }) + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts new file mode 100644 index 0000000000000..4664b49a08a50 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/delete.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; + +const paramsSchema = schema.object({ + names: schema.string(), +}); + +export const registerDeleteRoute = ({ router, license }: RouteDependencies): void => { + router.delete( + { + path: `${API_BASE_PATH}/{names}`, + validate: { + params: paramsSchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.dataClient; + const { names } = req.params; + const pipelineNames = names.split(','); + + const response: { itemsDeleted: string[]; errors: any[] } = { + itemsDeleted: [], + errors: [], + }; + + await Promise.all( + pipelineNames.map(pipelineName => { + return callAsCurrentUser('ingest.deletePipeline', { id: pipelineName }) + .then(() => response.itemsDeleted.push(pipelineName)) + .catch(e => + response.errors.push({ + name: pipelineName, + error: e, + }) + ); + }) + ); + + return res.ok({ body: response }); + }) + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/get.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/get.ts new file mode 100644 index 0000000000000..ec92262014272 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/get.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { deserializePipelines } from '../../../common/lib'; +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; + +const paramsSchema = schema.object({ + name: schema.string(), +}); + +export const registerGetRoutes = ({ + router, + license, + lib: { isEsError }, +}: RouteDependencies): void => { + // Get all pipelines + router.get( + { path: API_BASE_PATH, validate: false }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.dataClient; + + try { + const pipelines = await callAsCurrentUser('ingest.getPipeline'); + + return res.ok({ body: deserializePipelines(pipelines) }); + } catch (error) { + if (isEsError(error)) { + // ES returns 404 when there are no pipelines + // Instead, we return an empty array and 200 status back to the client + if (error.status === 404) { + return res.ok({ body: [] }); + } + + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + + return res.internalError({ body: error }); + } + }) + ); + + // Get single pipeline + router.get( + { + path: `${API_BASE_PATH}/{name}`, + validate: { + params: paramsSchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.dataClient; + const { name } = req.params; + + try { + const pipeline = await callAsCurrentUser('ingest.getPipeline', { id: name }); + + return res.ok({ + body: { + ...pipeline[name], + name, + }, + }); + } catch (error) { + if (isEsError(error)) { + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + + return res.internalError({ body: error }); + } + }) + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts new file mode 100644 index 0000000000000..58a4bf5617659 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { registerGetRoutes } from './get'; + +export { registerCreateRoute } from './create'; + +export { registerUpdateRoute } from './update'; + +export { registerPrivilegesRoute } from './privileges'; + +export { registerDeleteRoute } from './delete'; + +export { registerSimulateRoute } from './simulate'; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts new file mode 100644 index 0000000000000..2e1c11928959f --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts @@ -0,0 +1,62 @@ +/* + * 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 { RouteDependencies } from '../../types'; +import { API_BASE_PATH, APP_CLUSTER_REQUIRED_PRIVILEGES } from '../../../common/constants'; +import { Privileges } from '../../../../../../src/plugins/es_ui_shared/public'; + +const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = {}): string[] => + Object.keys(privilegesObject).reduce((privileges: string[], privilegeName: string): string[] => { + if (!privilegesObject[privilegeName]) { + privileges.push(privilegeName); + } + return privileges; + }, []); + +export const registerPrivilegesRoute = ({ license, router }: RouteDependencies) => { + router.get( + { + path: `${API_BASE_PATH}/privileges`, + validate: false, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { + core: { + elasticsearch: { dataClient }, + }, + } = ctx; + + const privilegesResult: Privileges = { + hasAllPrivileges: true, + missingPrivileges: { + cluster: [], + }, + }; + + try { + const { has_all_requested: hasAllPrivileges, cluster } = await dataClient.callAsCurrentUser( + 'transport.request', + { + path: '/_security/user/_has_privileges', + method: 'POST', + body: { + cluster: APP_CLUSTER_REQUIRED_PRIVILEGES, + }, + } + ); + + if (!hasAllPrivileges) { + privilegesResult.missingPrivileges.cluster = extractMissingPrivileges(cluster); + } + + privilegesResult.hasAllPrivileges = hasAllPrivileges; + + return res.ok({ body: privilegesResult }); + } catch (e) { + return res.internalError(e); + } + }) + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/simulate.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/simulate.ts new file mode 100644 index 0000000000000..ca5fc78d118fd --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/simulate.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; + +const bodySchema = schema.object({ + pipeline: schema.object({ + description: schema.string(), + processors: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), + version: schema.maybe(schema.number()), + on_failure: schema.maybe(schema.arrayOf(schema.recordOf(schema.string(), schema.any()))), + }), + documents: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), + verbose: schema.maybe(schema.boolean()), +}); + +export const registerSimulateRoute = ({ + router, + license, + lib: { isEsError }, +}: RouteDependencies): void => { + router.post( + { + path: `${API_BASE_PATH}/simulate`, + validate: { + body: bodySchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.dataClient; + + const { pipeline, documents, verbose } = req.body; + + try { + const response = await callAsCurrentUser('ingest.simulate', { + verbose, + body: { + pipeline, + docs: documents, + }, + }); + + return res.ok({ body: response }); + } catch (error) { + if (isEsError(error)) { + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + + return res.internalError({ body: error }); + } + }) + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/update.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/update.ts new file mode 100644 index 0000000000000..a6fdee47f0ecf --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/update.ts @@ -0,0 +1,67 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; + +const bodySchema = schema.object({ + description: schema.string(), + processors: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), + version: schema.maybe(schema.number()), + on_failure: schema.maybe(schema.arrayOf(schema.recordOf(schema.string(), schema.any()))), +}); + +const paramsSchema = schema.object({ + name: schema.string(), +}); + +export const registerUpdateRoute = ({ + router, + license, + lib: { isEsError }, +}: RouteDependencies): void => { + router.put( + { + path: `${API_BASE_PATH}/{name}`, + validate: { + body: bodySchema, + params: paramsSchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.core.elasticsearch.dataClient; + const { name } = req.params; + const { description, processors, version, on_failure } = req.body; + + try { + // Verify pipeline exists; ES will throw 404 if it doesn't + await callAsCurrentUser('ingest.getPipeline', { id: name }); + + const response = await callAsCurrentUser('ingest.putPipeline', { + id: name, + body: { + description, + processors, + version, + on_failure, + }, + }); + + return res.ok({ body: response }); + } catch (error) { + if (isEsError(error)) { + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + + return res.internalError({ body: error }); + } + }) + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/index.ts new file mode 100644 index 0000000000000..f703a460143f4 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/index.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 { RouteDependencies } from '../types'; + +import { + registerGetRoutes, + registerCreateRoute, + registerUpdateRoute, + registerPrivilegesRoute, + registerDeleteRoute, + registerSimulateRoute, +} from './api'; + +export class ApiRoutes { + setup(dependencies: RouteDependencies) { + registerGetRoutes(dependencies); + registerCreateRoute(dependencies); + registerUpdateRoute(dependencies); + registerPrivilegesRoute(dependencies); + registerDeleteRoute(dependencies); + registerSimulateRoute(dependencies); + } +} diff --git a/x-pack/plugins/ingest_pipelines/server/services/index.ts b/x-pack/plugins/ingest_pipelines/server/services/index.ts new file mode 100644 index 0000000000000..b7a45e59549eb --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/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 { License } from './license'; diff --git a/x-pack/plugins/ingest_pipelines/server/services/license.ts b/x-pack/plugins/ingest_pipelines/server/services/license.ts new file mode 100644 index 0000000000000..0a4748bd0ace0 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/services/license.ts @@ -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 { Logger } from 'src/core/server'; +import { + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, +} from 'kibana/server'; + +import { LicensingPluginSetup } from '../../../licensing/server'; +import { LicenseType } from '../../../licensing/common/types'; + +export interface LicenseStatus { + isValid: boolean; + message?: string; +} + +interface SetupSettings { + pluginId: string; + minimumLicenseType: LicenseType; + defaultErrorMessage: string; +} + +export class License { + private licenseStatus: LicenseStatus = { + isValid: false, + message: 'Invalid License', + }; + + setup( + { pluginId, minimumLicenseType, defaultErrorMessage }: SetupSettings, + { licensing, logger }: { licensing: LicensingPluginSetup; logger: Logger } + ) { + licensing.license$.subscribe(license => { + const { state, message } = license.check(pluginId, minimumLicenseType); + const hasRequiredLicense = state === 'valid'; + + if (hasRequiredLicense) { + this.licenseStatus = { isValid: true }; + } else { + this.licenseStatus = { + isValid: false, + message: message || defaultErrorMessage, + }; + if (message) { + logger.info(message); + } + } + }); + } + + guardApiRoute(handler: RequestHandler) { + const license = this; + + return function licenseCheck( + ctx: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) { + const licenseStatus = license.getStatus(); + + if (!licenseStatus.isValid) { + return response.customError({ + body: { + message: licenseStatus.message || '', + }, + statusCode: 403, + }); + } + + return handler(ctx, request, response); + }; + } + + getStatus() { + return this.licenseStatus; + } +} diff --git a/x-pack/plugins/ingest_pipelines/server/types.ts b/x-pack/plugins/ingest_pipelines/server/types.ts new file mode 100644 index 0000000000000..0135ae8e2f07d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { License } from './services'; +import { isEsError } from './lib'; + +export interface Dependencies { + licensing: LicensingPluginSetup; +} + +export interface RouteDependencies { + router: IRouter; + license: License; + lib: { + isEsError: typeof isEsError; + }; +} diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx new file mode 100644 index 0000000000000..f295f88a58e5f --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -0,0 +1,118 @@ +/* + * 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 { AppMountParameters, CoreSetup } from 'kibana/public'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { render, unmountComponentAtNode } from 'react-dom'; + +import rison from 'rison-node'; +import { DashboardConstants } from '../../../../../src/plugins/dashboard/public'; +import { Storage } from '../../../../../src/plugins/kibana_utils/public'; + +import { LensReportManager, setReportManager, trackUiEvent } from '../lens_ui_telemetry'; + +import { App } from './app'; +import { EditorFrameStart } from '../types'; +import { addEmbeddableToDashboardUrl, getUrlVars, isRisonObject } from '../helpers'; +import { addHelpMenuToAppChrome } from '../help_menu_util'; +import { SavedObjectIndexStore } from '../persistence'; +import { LensPluginStartDependencies } from '../plugin'; + +export async function mountApp( + core: CoreSetup, + params: AppMountParameters, + createEditorFrame: EditorFrameStart['createInstance'] +) { + const [coreStart, startDependencies] = await core.getStartServices(); + const { data: dataStart, navigation } = startDependencies; + const savedObjectsClient = coreStart.savedObjects.client; + addHelpMenuToAppChrome(coreStart.chrome, coreStart.docLinks); + + const instance = await createEditorFrame(); + + setReportManager( + new LensReportManager({ + storage: new Storage(localStorage), + http: core.http, + }) + ); + const updateUrlTime = (urlVars: Record): void => { + const decoded = rison.decode(urlVars._g); + if (!isRisonObject(decoded)) { + return; + } + // @ts-ignore + decoded.time = dataStart.query.timefilter.timefilter.getTime(); + urlVars._g = rison.encode(decoded); + }; + const redirectTo = ( + routeProps: RouteComponentProps<{ id?: string }>, + addToDashboardMode: boolean, + id?: string + ) => { + if (!id) { + routeProps.history.push('/lens'); + } else if (!addToDashboardMode) { + routeProps.history.push(`/lens/edit/${id}`); + } else if (addToDashboardMode && id) { + routeProps.history.push(`/lens/edit/${id}`); + const lastDashboardLink = coreStart.chrome.navLinks.get('kibana:dashboard'); + if (!lastDashboardLink || !lastDashboardLink.url) { + throw new Error('Cannot get last dashboard url'); + } + const urlVars = getUrlVars(lastDashboardLink.url); + updateUrlTime(urlVars); // we need to pass in timerange in query params directly + const dashboardUrl = addEmbeddableToDashboardUrl(lastDashboardLink.url, id, urlVars); + window.history.pushState({}, '', dashboardUrl); + } + }; + + const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { + trackUiEvent('loaded'); + const addToDashboardMode = + !!routeProps.location.search && + routeProps.location.search.includes( + DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM + ); + return ( + redirectTo(routeProps, addToDashboardMode, id)} + addToDashboardMode={addToDashboardMode} + /> + ); + }; + + function NotFound() { + trackUiEvent('loaded_404'); + return ; + } + + render( + + + + + + + + + , + params.element + ); + return () => { + instance.unmount(); + unmountComponentAtNode(params.element); + }; +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap new file mode 100644 index 0000000000000..76063d230bdb6 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`datatable_expression DatatableComponent it renders the title and value 1`] = ` + + + +`; diff --git a/x-pack/plugins/lens/public/datatable_visualization/_visualization.scss b/x-pack/plugins/lens/public/datatable_visualization/_visualization.scss index e36326d710f72..7d95d73143870 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/_visualization.scss +++ b/x-pack/plugins/lens/public/datatable_visualization/_visualization.scss @@ -1,3 +1,13 @@ .lnsDataTable { align-self: flex-start; } + +.lnsDataTable__filter { + opacity: 0; + transition: opacity $euiAnimSpeedNormal ease-in-out; +} + +.lnsDataTable__cell:hover .lnsDataTable__filter, +.lnsDataTable__filter:focus-within { + opacity: 1; +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx new file mode 100644 index 0000000000000..6d5b1153ad1bc --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -0,0 +1,156 @@ +/* + * 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 { shallow } from 'enzyme'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { datatable, DatatableComponent } from './expression'; +import { LensMultiTable } from '../types'; +import { DatatableProps } from './expression'; +import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; +import { IFieldFormat } from '../../../../../src/plugins/data/public'; +import { IAggType } from 'src/plugins/data/public'; +const executeTriggerActions = jest.fn(); + +function sampleArgs() { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a', meta: { type: 'count' } }, + { id: 'b', name: 'b', meta: { type: 'date_histogram', aggConfigParams: { field: 'b' } } }, + { id: 'c', name: 'c', meta: { type: 'cardinality' } }, + ], + rows: [{ a: 10110, b: 1588024800000, c: 3 }], + }, + }, + }; + + const args: DatatableProps['args'] = { + title: 'My fanci metric chart', + columns: { + columnIds: ['a', 'b', 'c'], + type: 'lens_datatable_columns', + }, + }; + + return { data, args }; +} + +describe('datatable_expression', () => { + describe('datatable renders', () => { + test('it renders with the specified data and args', () => { + const { data, args } = sampleArgs(); + const result = datatable.fn(data, args, createMockExecutionContext()); + + expect(result).toEqual({ + type: 'render', + as: 'lens_datatable_renderer', + value: { data, args }, + }); + }); + }); + + describe('DatatableComponent', () => { + test('it renders the title and value', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + x as IFieldFormat} + executeTriggerActions={executeTriggerActions} + getType={jest.fn()} + /> + ) + ).toMatchSnapshot(); + }); + + test('it invokes executeTriggerActions with correct context on click on top value', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + x as IFieldFormat} + executeTriggerActions={executeTriggerActions} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + /> + ); + + wrapper + .find('[data-test-subj="lensDatatableFilterOut"]') + .first() + .simulate('click'); + + expect(executeTriggerActions).toHaveBeenCalledWith('VALUE_CLICK_TRIGGER', { + data: { + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 10110, + }, + ], + negate: true, + }, + timeFieldName: undefined, + }); + }); + + test('it invokes executeTriggerActions with correct context on click on timefield', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + x as IFieldFormat} + executeTriggerActions={executeTriggerActions} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + /> + ); + + wrapper + .find('[data-test-subj="lensDatatableFilterFor"]') + .at(3) + .simulate('click'); + + expect(executeTriggerActions).toHaveBeenCalledWith('VALUE_CLICK_TRIGGER', { + data: { + data: [ + { + column: 1, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + }, + timeFieldName: 'b', + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 772ee13168d02..71d29be1744bb 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -7,7 +7,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { EuiBasicTable } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n/react'; +import { EuiBasicTable, EuiFlexGroup, EuiButtonIcon, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { IAggType } from 'src/plugins/data/public'; import { FormatFactory, LensMultiTable } from '../types'; import { ExpressionFunctionDefinition, @@ -15,7 +17,10 @@ import { IInterpreterRenderHandlers, } from '../../../../../src/plugins/expressions/public'; import { VisualizationContainer } from '../visualization_container'; - +import { ValueClickTriggerContext } from '../../../../../src/plugins/embeddable/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { getExecuteTriggerActions } from '../services'; export interface DatatableColumns { columnIds: string[]; } @@ -30,6 +35,12 @@ export interface DatatableProps { args: Args; } +type DatatableRenderProps = DatatableProps & { + formatFactory: FormatFactory; + executeTriggerActions: UiActionsStart['executeTriggerActions']; + getType: (name: string) => IAggType; +}; + export interface DatatableRender { type: 'render'; as: 'lens_datatable_renderer'; @@ -100,9 +111,10 @@ export const datatableColumns: ExpressionFunctionDefinition< }, }; -export const getDatatableRenderer = ( - formatFactory: Promise -): ExpressionRenderDefinition => ({ +export const getDatatableRenderer = (dependencies: { + formatFactory: Promise; + getType: Promise<(name: string) => IAggType>; +}): ExpressionRenderDefinition => ({ name: 'lens_datatable_renderer', displayName: i18n.translate('xpack.lens.datatable.visualizationName', { defaultMessage: 'Datatable', @@ -115,9 +127,18 @@ export const getDatatableRenderer = ( config: DatatableProps, handlers: IInterpreterRenderHandlers ) => { - const resolvedFormatFactory = await formatFactory; + const resolvedFormatFactory = await dependencies.formatFactory; + const executeTriggerActions = getExecuteTriggerActions(); + const resolvedGetType = await dependencies.getType; ReactDOM.render( - , + + + , domNode, () => { handlers.done(); @@ -127,7 +148,7 @@ export const getDatatableRenderer = ( }, }); -function DatatableComponent(props: DatatableProps & { formatFactory: FormatFactory }) { +export function DatatableComponent(props: DatatableRenderProps) { const [firstTable] = Object.values(props.data.tables); const formatters: Record> = {}; @@ -135,6 +156,29 @@ function DatatableComponent(props: DatatableProps & { formatFactory: FormatFacto formatters[column.id] = props.formatFactory(column.formatHint); }); + const handleFilterClick = (field: string, value: unknown, colIndex: number, negate = false) => { + const col = firstTable.columns[colIndex]; + const isDateHistogram = col.meta?.type === 'date_histogram'; + const timeFieldName = negate && isDateHistogram ? undefined : col?.meta?.aggConfigParams?.field; + const rowIndex = firstTable.rows.findIndex(row => row[field] === value); + + const context: ValueClickTriggerContext = { + data: { + negate, + data: [ + { + row: rowIndex, + column: colIndex, + value, + table: firstTable, + }, + ], + }, + timeFieldName, + }; + props.executeTriggerActions(VIS_EVENT_TO_TRIGGER.filter, context); + }; + return ( { const col = firstTable.columns.find(c => c.id === field); + const colIndex = firstTable.columns.findIndex(c => c.id === field); + + const filterable = col?.meta?.type && props.getType(col.meta.type)?.type === 'buckets'; return { field, name: (col && col.name) || '', + render: (value: unknown) => { + const formattedValue = formatters[field]?.convert(value); + const fieldName = col?.meta?.aggConfigParams?.field; + + if (filterable) { + return ( + + {formattedValue} + + + + handleFilterClick(field, value, colIndex)} + /> + + + + handleFilterClick(field, value, colIndex, true)} + /> + + + + + + ); + } + return {formattedValue}; + }, }; }) .filter(({ field }) => !!field)} - items={ - firstTable - ? firstTable.rows.map(row => { - const formattedRow: Record = {}; - Object.entries(formatters).forEach(([columnId, formatter]) => { - formattedRow[columnId] = formatter.convert(row[columnId]); - }); - return formattedRow; - }) - : [] - } + items={firstTable ? firstTable.rows : []} /> ); diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index ff036aadfd4cf..44894d31da51d 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -4,12 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup } from 'kibana/public'; +import { CoreSetup, CoreStart } from 'kibana/public'; import { datatableVisualization } from './visualization'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import { datatable, datatableColumns, getDatatableRenderer } from './expression'; import { EditorFrameSetup, FormatFactory } from '../types'; +import { setExecuteTriggerActions } from '../services'; +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +interface DatatableVisualizationPluginStartPlugins { + uiActions: UiActionsStart; + data: DataPublicPluginStart; +} export interface DatatableVisualizationPluginSetupPlugins { expressions: ExpressionsSetup; formatFactory: Promise; @@ -20,12 +27,22 @@ export class DatatableVisualization { constructor() {} setup( - _core: CoreSetup | null, + core: CoreSetup, { expressions, formatFactory, editorFrame }: DatatableVisualizationPluginSetupPlugins ) { expressions.registerFunction(() => datatableColumns); expressions.registerFunction(() => datatable); - expressions.registerRenderer(() => getDatatableRenderer(formatFactory)); + expressions.registerRenderer(() => + getDatatableRenderer({ + formatFactory, + getType: core + .getStartServices() + .then(([_, { data: dataStart }]) => dataStart.search.aggs.types.get), + }) + ); editorFrame.registerVisualization(datatableVisualization); } + start(core: CoreStart, { uiActions }: DatatableVisualizationPluginStartPlugins) { + setExecuteTriggerActions(uiActions.executeTriggerActions); + } } diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 48729448b2ea5..21bbcce68bf36 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -126,8 +126,8 @@ export const datatableVisualization: Visualization< ], }, previewIcon: chartTableSVG, - // dont show suggestions for reduced versions or single-line tables - hide: table.changeType === 'reduced' || !table.isMultiRow, + // tables are hidden from suggestion bar, but used for drag & drop and chart switching + hide: true, }, ]; }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index 0ef5f6d1a5470..6cd15e3c93e4e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -96,9 +96,7 @@ export class Embeddable extends AbstractEmbeddable { + return isObject(value); +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 02471b935c97c..f26fd39a60c0e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -1552,6 +1552,62 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table.columns.length).toBe(1); expect(suggestions[0].table.columns[0].operation.label).toBe('Sum of field1'); }); + + it('contains a reordering suggestion when there are exactly 2 buckets', () => { + const initialState = testInitialState(); + const state: IndexPatternPrivateState = { + indexPatternRefs: [], + existingFields: {}, + currentIndexPatternId: '1', + indexPatterns: expectedIndexPatterns, + showEmptyFields: true, + layers: { + first: { + ...initialState.layers.first, + columns: { + id1: { + label: 'Date histogram', + dataType: 'date', + isBucketed: true, + + operationType: 'date_histogram', + sourceField: 'field2', + params: { + interval: 'd', + }, + }, + id2: { + label: 'Top 5', + dataType: 'string', + isBucketed: true, + + operationType: 'terms', + sourceField: 'field1', + params: { size: 5, orderBy: { type: 'alphabetical' }, orderDirection: 'asc' }, + }, + id3: { + label: 'Average of field1', + dataType: 'number', + isBucketed: false, + + operationType: 'avg', + sourceField: 'field1', + }, + }, + columnOrder: ['id1', 'id2', 'id3'], + }, + }, + }; + + const suggestions = getDatasourceSuggestionsFromCurrentState(state); + expect(suggestions).toContainEqual( + expect.objectContaining({ + table: expect.objectContaining({ + changeType: 'reorder', + }), + }) + ); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 44963722f8afc..487c1bf759fc2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -486,7 +486,7 @@ function createChangedNestingSuggestion(state: IndexPatternPrivateState, layerId layerId, updatedLayer, label: getNestedTitle([layer.columns[secondBucket], layer.columns[firstBucket]]), - changeType: 'extended', + changeType: 'reorder', }); } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts new file mode 100644 index 0000000000000..a6acc61922177 --- /dev/null +++ b/x-pack/plugins/lens/public/plugin.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public'; +import { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public'; +import { EmbeddableSetup, EmbeddableStart } from 'src/plugins/embeddable/public'; +import { ExpressionsSetup, ExpressionsStart } from 'src/plugins/expressions/public'; +import { VisualizationsSetup } from 'src/plugins/visualizations/public'; +import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; +import { KibanaLegacySetup } from 'src/plugins/kibana_legacy/public'; +import { EditorFrameService } from './editor_frame_service'; +import { IndexPatternDatasource } from './indexpattern_datasource'; +import { XyVisualization } from './xy_visualization'; +import { MetricVisualization } from './metric_visualization'; +import { DatatableVisualization } from './datatable_visualization'; +import { stopReportManager } from './lens_ui_telemetry'; + +import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; +import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common'; +import { EditorFrameStart } from './types'; +import { getLensAliasConfig } from './vis_type_alias'; + +import './index.scss'; + +export interface LensPluginSetupDependencies { + kibanaLegacy: KibanaLegacySetup; + expressions: ExpressionsSetup; + data: DataPublicPluginSetup; + embeddable?: EmbeddableSetup; + visualizations: VisualizationsSetup; +} + +export interface LensPluginStartDependencies { + data: DataPublicPluginStart; + embeddable: EmbeddableStart; + expressions: ExpressionsStart; + navigation: NavigationPublicPluginStart; + uiActions: UiActionsStart; +} + +export class LensPlugin { + private datatableVisualization: DatatableVisualization; + private editorFrameService: EditorFrameService; + private createEditorFrame: EditorFrameStart['createInstance'] | null = null; + private indexpatternDatasource: IndexPatternDatasource; + private xyVisualization: XyVisualization; + private metricVisualization: MetricVisualization; + + constructor() { + this.datatableVisualization = new DatatableVisualization(); + this.editorFrameService = new EditorFrameService(); + this.indexpatternDatasource = new IndexPatternDatasource(); + this.xyVisualization = new XyVisualization(); + this.metricVisualization = new MetricVisualization(); + } + + setup( + core: CoreSetup, + { kibanaLegacy, expressions, data, embeddable, visualizations }: LensPluginSetupDependencies + ) { + const editorFrameSetupInterface = this.editorFrameService.setup(core, { + data, + embeddable, + expressions, + }); + const dependencies = { + expressions, + data, + editorFrame: editorFrameSetupInterface, + formatFactory: core + .getStartServices() + .then(([_, { data: dataStart }]) => dataStart.fieldFormats.deserialize), + }; + this.indexpatternDatasource.setup(core, dependencies); + this.xyVisualization.setup(core, dependencies); + this.datatableVisualization.setup(core, dependencies); + this.metricVisualization.setup(core, dependencies); + + visualizations.registerAlias(getLensAliasConfig()); + + kibanaLegacy.registerLegacyApp({ + id: 'lens', + title: NOT_INTERNATIONALIZED_PRODUCT_NAME, + mount: async (params: AppMountParameters) => { + const { mountApp } = await import('./app_plugin/mounter'); + return mountApp(core, params, this.createEditorFrame!); + }, + }); + } + + start(core: CoreStart, startDependencies: LensPluginStartDependencies) { + this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; + this.xyVisualization.start(core, startDependencies); + this.datatableVisualization.start(core, startDependencies); + } + + stop() { + stopReportManager(); + } +} diff --git a/x-pack/plugins/lens/public/plugin.tsx b/x-pack/plugins/lens/public/plugin.tsx deleted file mode 100644 index 8d760eb0df501..0000000000000 --- a/x-pack/plugins/lens/public/plugin.tsx +++ /dev/null @@ -1,208 +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 { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; -import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { render, unmountComponentAtNode } from 'react-dom'; -import rison, { RisonObject, RisonValue } from 'rison-node'; -import { isObject } from 'lodash'; - -import { AppMountParameters, CoreSetup, CoreStart } from 'kibana/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public'; -import { EmbeddableSetup, EmbeddableStart } from 'src/plugins/embeddable/public'; -import { ExpressionsSetup, ExpressionsStart } from 'src/plugins/expressions/public'; -import { VisualizationsSetup } from 'src/plugins/visualizations/public'; -import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; -import { KibanaLegacySetup } from 'src/plugins/kibana_legacy/public'; -import { DashboardConstants } from '../../../../src/plugins/dashboard/public'; -import { Storage } from '../../../../src/plugins/kibana_utils/public'; -import { EditorFrameService } from './editor_frame_service'; -import { IndexPatternDatasource } from './indexpattern_datasource'; -import { addHelpMenuToAppChrome } from './help_menu_util'; -import { SavedObjectIndexStore } from './persistence'; -import { XyVisualization } from './xy_visualization'; -import { MetricVisualization } from './metric_visualization'; -import { DatatableVisualization } from './datatable_visualization'; -import { App } from './app_plugin'; -import { - LensReportManager, - setReportManager, - stopReportManager, - trackUiEvent, -} from './lens_ui_telemetry'; - -import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common'; -import { addEmbeddableToDashboardUrl, getUrlVars } from './helpers'; -import { EditorFrameStart } from './types'; -import { getLensAliasConfig } from './vis_type_alias'; - -import './index.scss'; - -export interface LensPluginSetupDependencies { - kibanaLegacy: KibanaLegacySetup; - expressions: ExpressionsSetup; - data: DataPublicPluginSetup; - embeddable?: EmbeddableSetup; - visualizations: VisualizationsSetup; -} - -export interface LensPluginStartDependencies { - data: DataPublicPluginStart; - embeddable: EmbeddableStart; - expressions: ExpressionsStart; - navigation: NavigationPublicPluginStart; - uiActions: UiActionsStart; -} - -export const isRisonObject = (value: RisonValue): value is RisonObject => { - return isObject(value); -}; -export class LensPlugin { - private datatableVisualization: DatatableVisualization; - private editorFrameService: EditorFrameService; - private createEditorFrame: EditorFrameStart['createInstance'] | null = null; - private indexpatternDatasource: IndexPatternDatasource; - private xyVisualization: XyVisualization; - private metricVisualization: MetricVisualization; - - constructor() { - this.datatableVisualization = new DatatableVisualization(); - this.editorFrameService = new EditorFrameService(); - this.indexpatternDatasource = new IndexPatternDatasource(); - this.xyVisualization = new XyVisualization(); - this.metricVisualization = new MetricVisualization(); - } - - setup( - core: CoreSetup, - { kibanaLegacy, expressions, data, embeddable, visualizations }: LensPluginSetupDependencies - ) { - const editorFrameSetupInterface = this.editorFrameService.setup(core, { - data, - embeddable, - expressions, - }); - const dependencies = { - expressions, - data, - editorFrame: editorFrameSetupInterface, - formatFactory: core - .getStartServices() - .then(([_, { data: dataStart }]) => dataStart.fieldFormats.deserialize), - }; - this.indexpatternDatasource.setup(core, dependencies); - this.xyVisualization.setup(core, dependencies); - this.datatableVisualization.setup(core, dependencies); - this.metricVisualization.setup(core, dependencies); - - visualizations.registerAlias(getLensAliasConfig()); - - kibanaLegacy.registerLegacyApp({ - id: 'lens', - title: NOT_INTERNATIONALIZED_PRODUCT_NAME, - mount: async (params: AppMountParameters) => { - const [coreStart, startDependencies] = await core.getStartServices(); - const { data: dataStart, navigation } = startDependencies; - const savedObjectsClient = coreStart.savedObjects.client; - addHelpMenuToAppChrome(coreStart.chrome, coreStart.docLinks); - - const instance = await this.createEditorFrame!(); - - setReportManager( - new LensReportManager({ - storage: new Storage(localStorage), - http: core.http, - }) - ); - const updateUrlTime = (urlVars: Record): void => { - const decoded = rison.decode(urlVars._g); - if (!isRisonObject(decoded)) { - return; - } - // @ts-ignore - decoded.time = dataStart.query.timefilter.timefilter.getTime(); - urlVars._g = rison.encode(decoded); - }; - const redirectTo = ( - routeProps: RouteComponentProps<{ id?: string }>, - addToDashboardMode: boolean, - id?: string - ) => { - if (!id) { - routeProps.history.push('/lens'); - } else if (!addToDashboardMode) { - routeProps.history.push(`/lens/edit/${id}`); - } else if (addToDashboardMode && id) { - routeProps.history.push(`/lens/edit/${id}`); - const lastDashboardLink = coreStart.chrome.navLinks.get('kibana:dashboard'); - if (!lastDashboardLink || !lastDashboardLink.url) { - throw new Error('Cannot get last dashboard url'); - } - const urlVars = getUrlVars(lastDashboardLink.url); - updateUrlTime(urlVars); // we need to pass in timerange in query params directly - const dashboardUrl = addEmbeddableToDashboardUrl(lastDashboardLink.url, id, urlVars); - window.history.pushState({}, '', dashboardUrl); - } - }; - - const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { - trackUiEvent('loaded'); - const addToDashboardMode = - !!routeProps.location.search && - routeProps.location.search.includes( - DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM - ); - return ( - redirectTo(routeProps, addToDashboardMode, id)} - addToDashboardMode={addToDashboardMode} - /> - ); - }; - - function NotFound() { - trackUiEvent('loaded_404'); - return ; - } - - render( - - - - - - - - - , - params.element - ); - return () => { - instance.unmount(); - unmountComponentAtNode(params.element); - }; - }, - }); - } - - start(core: CoreStart, startDependencies: LensPluginStartDependencies) { - this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; - this.xyVisualization.start(core, startDependencies); - } - - stop() { - stopReportManager(); - } -} diff --git a/x-pack/plugins/lens/public/services.ts b/x-pack/plugins/lens/public/services.ts new file mode 100644 index 0000000000000..a66743dde2661 --- /dev/null +++ b/x-pack/plugins/lens/public/services.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. + */ + +import { createGetterSetter } from '../../../../src/plugins/kibana_utils/public'; +import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; + +export const [getExecuteTriggerActions, setExecuteTriggerActions] = createGetterSetter< + UiActionsStart['executeTriggerActions'] +>('executeTriggerActions'); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index ed0af8545f012..04efc642793b0 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -103,9 +103,16 @@ export interface TableSuggestion { * * `unchanged` means the table is the same in the currently active configuration * * `reduced` means the table is a reduced version of the currently active table (some columns dropped, but not all of them) * * `extended` means the table is an extended version of the currently active table (added one or multiple additional columns) + * * `reorder` means the table columns have changed order, which change the data as well * * `layers` means the change is a change to the layer structure, not to the table */ -export type TableChangeType = 'initial' | 'unchanged' | 'reduced' | 'extended' | 'layers'; +export type TableChangeType = + | 'initial' + | 'unchanged' + | 'reduced' + | 'extended' + | 'reorder' + | 'layers'; export interface DatasourceSuggestion { state: T; diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index bef53c2fd266e..f8f467b25643b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -6,6 +6,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` > ('executeTriggerActions'); diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 9b068b0ca5ef0..d28a803790822 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -142,7 +142,7 @@ export const buildExpression = ( .concat(layer.splitAccessor ? [layer.splitAccessor] : []) .forEach(accessor => { const operation = datasource.getOperationForColumnId(accessor); - if (operation && operation.label) { + if (operation?.label) { columnToLabel[accessor] = operation.label; } }); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx index 8db00aba0e36d..92a09f361230c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -27,6 +27,145 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; const executeTriggerActions = jest.fn(); +const dateHistogramData: LensMultiTable = { + type: 'lens_multitable', + tables: { + timeLayer: { + type: 'kibana_datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date_histogram', + indexPatternId: 'indexPatternId', + aggConfigParams: { + field: 'order_date', + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, + }, + }, + formatHint: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'terms', + indexPatternId: 'indexPatternId', + aggConfigParams: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }, + formatHint: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', + }, + }, + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'count', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'number' }, + }, + ], + }, + }, + dateRange: { + fromDate: new Date('2020-04-01T16:14:16.246Z'), + toDate: new Date('2020-04-01T17:15:41.263Z'), + }, +}; + +const dateHistogramLayer: LayerArgs = { + layerId: 'timeLayer', + hide: false, + xAccessor: 'xAccessorId', + yScaleType: 'linear', + xScaleType: 'time', + isHistogram: true, + splitAccessor: 'splitAccessorId', + seriesType: 'bar_stacked', + accessors: ['yAccessorId'], +}; + const createSampleDatatableWithRows = (rows: KibanaDatatableRow[]): KibanaDatatable => ({ type: 'kibana_datatable', columns: [ @@ -284,7 +423,7 @@ describe('xy_expression', () => { Object { "max": 1546491600000, "min": 1546405200000, - "minInterval": 1728000, + "minInterval": undefined, } `); }); @@ -449,6 +588,39 @@ describe('xy_expression', () => { expect(component.find(Settings).prop('rotation')).toEqual(90); }); + test('onBrushEnd returns correct context data for date histogram data', () => { + const { args } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + wrapper + .find(Settings) + .first() + .prop('onBrushEnd')!({ x: [1585757732783, 1585758880838] }); + + expect(executeTriggerActions).toHaveBeenCalledWith('SELECT_RANGE_TRIGGER', { + data: { + column: 0, + table: dateHistogramData.tables.timeLayer, + range: [1585757732783, 1585758880838], + }, + timeFieldName: 'order_date', + }); + }); + test('onElementClick returns correct context data', () => { const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1', mark: null }; const series = { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 85cf5753befd7..81ae57a5ee638 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -29,14 +29,17 @@ import { import { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { ValueClickTriggerContext } from '../../../../../src/plugins/embeddable/public'; +import { + ValueClickTriggerContext, + RangeSelectTriggerContext, +} from '../../../../../src/plugins/embeddable/public'; import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public'; import { LensMultiTable, FormatFactory } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart } from './state_helpers'; +import { getExecuteTriggerActions } from '../services'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; -import { getExecuteTriggerActions } from './services'; import { parseInterval } from '../../../../../src/plugins/data/common'; type InferPropType = T extends React.FunctionComponent ? P : T; @@ -218,8 +221,32 @@ export function XYChart({ const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; function calculateMinInterval() { - // add minInterval only for single row value as it cannot be determined from dataset - if (data.dateRange && layers.every(layer => data.tables[layer.layerId].rows.length <= 1)) { + // check all the tables to see if all of the rows have the same timestamp + // that would mean that chart will draw a single bar + const isSingleTimestampInXDomain = () => { + const nonEmptyLayers = layers.filter( + layer => data.tables[layer.layerId].rows.length && layer.xAccessor + ); + + if (!nonEmptyLayers.length) { + return; + } + + const firstRowValue = + data.tables[nonEmptyLayers[0].layerId].rows[0][nonEmptyLayers[0].xAccessor!]; + for (const layer of nonEmptyLayers) { + if ( + layer.xAccessor && + data.tables[layer.layerId].rows.some(row => row[layer.xAccessor!] !== firstRowValue) + ) { + return false; + } + } + return true; + }; + + // add minInterval only for single point in domain + if (data.dateRange && isSingleTimestampInXDomain()) { if (xAxisColumn?.meta?.aggConfigParams?.interval !== 'auto') return parseInterval(xAxisColumn?.meta?.aggConfigParams?.interval)?.asMilliseconds(); @@ -231,14 +258,16 @@ export function XYChart({ return undefined; } - const xDomain = - data.dateRange && layers.every(l => l.xScaleType === 'time') - ? { - min: data.dateRange.fromDate.getTime(), - max: data.dateRange.toDate.getTime(), - minInterval: calculateMinInterval(), - } - : undefined; + const isTimeViz = data.dateRange && layers.every(l => l.xScaleType === 'time'); + + const xDomain = isTimeViz + ? { + min: data.dateRange?.fromDate.getTime(), + max: data.dateRange?.toDate.getTime(), + minInterval: calculateMinInterval(), + } + : undefined; + return ( { + if (!x) { + return; + } + const [min, max] = x; + // in the future we want to make it also for histogram + if (!xAxisColumn || !isTimeViz) { + return; + } + + const firstLayerWithData = + layers[layers.findIndex(layer => data.tables[layer.layerId].rows.length)]; + const table = data.tables[firstLayerWithData.layerId]; + + const xAxisColumnIndex = table.columns.findIndex( + el => el.id === firstLayerWithData.xAccessor + ); + const timeFieldName = table.columns[xAxisColumnIndex]?.meta?.aggConfigParams?.field; + + const context: RangeSelectTriggerContext = { + data: { + range: [min, max], + table, + column: xAxisColumnIndex, + }, + timeFieldName, + }; + executeTriggerActions(VIS_EVENT_TO_TRIGGER.brush, context); + }} onElementClick={([[geometry, series]]) => { // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue const xySeries = series as XYChartSeriesIdentifier; @@ -284,10 +342,8 @@ export function XYChart({ }); } - const xAxisFieldName: string | undefined = table.columns.find( - col => col.id === layer.xAccessor - )?.meta?.aggConfigParams?.field; - + const xAxisFieldName = table.columns.find(el => el.id === layer.xAccessor)?.meta + ?.aggConfigParams?.field; const timeFieldName = xDomain && xAxisFieldName; const context: ValueClickTriggerContext = { @@ -301,7 +357,6 @@ export function XYChart({ }, timeFieldName, }; - executeTriggerActions(VIS_EVENT_TO_TRIGGER.filter, context); }} /> diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index ddbd9d11b5fad..73ff88e97f479 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -186,7 +186,7 @@ describe('xy_suggestions', () => { isMultiRow: true, columns: [numCol('price'), numCol('quantity'), dateCol('date'), strCol('product')], layerId: 'first', - changeType: 'unchanged', + changeType: 'extended', label: 'Datasource title', }, keptLayerIds: [], @@ -196,6 +196,34 @@ describe('xy_suggestions', () => { expect(suggestion.title).toEqual('Datasource title'); }); + test('suggests only stacked bar chart when xy chart is inactive', () => { + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [dateCol('date'), numCol('price')], + layerId: 'first', + changeType: 'unchanged', + label: 'Datasource title', + }, + keptLayerIds: [], + }); + + expect(rest).toHaveLength(0); + expect(suggestion.title).toEqual('Bar chart'); + expect(suggestion.state).toEqual( + expect.objectContaining({ + layers: [ + expect.objectContaining({ + seriesType: 'bar_stacked', + xAccessor: 'date', + accessors: ['price'], + splitAccessor: undefined, + }), + ], + }) + ); + }); + test('hides reduced suggestions if there is a current state', () => { const [suggestion, ...rest] = getSuggestions({ table: { @@ -224,7 +252,7 @@ describe('xy_suggestions', () => { expect(suggestion.hide).toBeTruthy(); }); - test('does not hide reduced suggestions if xy visualization is not active', () => { + test('hides reduced suggestions if xy visualization is not active', () => { const [suggestion, ...rest] = getSuggestions({ table: { isMultiRow: true, @@ -236,7 +264,7 @@ describe('xy_suggestions', () => { }); expect(rest).toHaveLength(0); - expect(suggestion.hide).toBeFalsy(); + expect(suggestion.hide).toBeTruthy(); }); test('only makes a seriesType suggestion for unchanged table without split', () => { @@ -419,6 +447,44 @@ describe('xy_suggestions', () => { }); }); + test('changes column mappings when suggestion is reorder', () => { + const currentState: XYState = { + legend: { isVisible: true, position: 'bottom' }, + preferredSeriesType: 'bar', + layers: [ + { + accessors: ['price'], + layerId: 'first', + seriesType: 'bar', + splitAccessor: 'category', + xAccessor: 'product', + }, + ], + }; + const [suggestion, ...rest] = getSuggestions({ + table: { + isMultiRow: true, + columns: [strCol('category'), strCol('product'), numCol('price')], + layerId: 'first', + changeType: 'reorder', + }, + state: currentState, + keptLayerIds: [], + }); + + expect(rest).toHaveLength(0); + expect(suggestion.state).toEqual({ + ...currentState, + layers: [ + { + ...currentState.layers[0], + xAccessor: 'category', + splitAccessor: 'product', + }, + ], + }); + }); + test('overwrites column to dimension mappings if a date dimension is added', () => { (generateId as jest.Mock).mockReturnValueOnce('dummyCol'); const currentState: XYState = { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 5e9311bb1e928..abd7640344064 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -99,11 +99,14 @@ function getBucketMappings(table: TableSuggestion, currentState?: State) { // reverse the buckets before prioritization to always use the most inner // bucket of the highest-prioritized group as x value (don't use nested // buckets as split series) - const prioritizedBuckets = prioritizeColumns(buckets.reverse()); + const prioritizedBuckets = prioritizeColumns([...buckets].reverse()); if (!currentLayer || table.changeType === 'initial') { return prioritizedBuckets; } + if (table.changeType === 'reorder') { + return buckets; + } // if existing table is just modified, try to map buckets to the current dimensions const currentXColumnIndex = prioritizedBuckets.findIndex( @@ -175,12 +178,24 @@ function getSuggestionsForLayer({ keptLayerIds, }; - const isSameState = currentState && changeType === 'unchanged'; + // handles the simplest cases, acting as a chart switcher + if (!currentState && changeType === 'unchanged') { + return [ + { + ...buildSuggestion(options), + title: i18n.translate('xpack.lens.xySuggestions.barChartTitle', { + defaultMessage: 'Bar chart', + }), + }, + ]; + } + const isSameState = currentState && changeType === 'unchanged'; if (!isSameState) { return buildSuggestion(options); } + // Suggestions are either changing the data, or changing the way the data is used const sameStateSuggestions: Array> = []; // if current state is using the same data, suggest same chart with different presentational configuration @@ -374,8 +389,11 @@ function buildSuggestion({ return { title, score: getScore(yValues, splitBy, changeType), - // don't advertise chart of same type but with less data - hide: currentState && changeType === 'reduced', + hide: + // Only advertise very clear changes when XY chart is not active + (!currentState && changeType !== 'unchanged' && changeType !== 'extended') || + // Don't advertise removing dimensions + (currentState && changeType === 'reduced'), state, previewIcon: getIconForSeries(seriesType), }; diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts index 4cc330d40efd7..0541d9636577b 100644 --- a/x-pack/plugins/lens/server/migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations.test.ts @@ -160,7 +160,7 @@ describe('Lens migrations', () => { }); describe('7.8.0 auto timestamp', () => { - const context = {} as SavedObjectMigrationContext; + const context = ({ log: { warning: () => {} } } as unknown) as SavedObjectMigrationContext; const example = { type: 'lens', diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations.ts index 583fba1a4a999..a15e2b3692d02 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { cloneDeep, flow } from 'lodash'; +import { cloneDeep } from 'lodash'; import { fromExpression, toExpression, Ast, ExpressionFunctionAST } from '@kbn/interpreter/common'; import { SavedObjectMigrationFn } from 'src/core/server'; @@ -156,5 +156,5 @@ export const migrations: Record> = { }, // The order of these migrations matter, since the timefield migration relies on the aggConfigs // sitting directly on the esaggs as an argument and not a nested function (which lens_auto_date was). - '7.8.0': flow(removeLensAutoDate, addTimeFieldToEsaggs), + '7.8.0': (doc, context) => addTimeFieldToEsaggs(removeLensAutoDate(doc, context), context), }; diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index fd972219563a8..e95fe9500746c 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -174,8 +174,6 @@ export const COLOR_MAP_TYPE = { ORDINAL: 'ORDINAL', }; -export const COLOR_PALETTE_MAX_SIZE = 10; - export const CATEGORICAL_DATA_TYPES = ['string', 'ip', 'boolean']; export const ORDINAL_DATA_TYPES = ['number', 'date']; @@ -217,3 +215,9 @@ export enum SCALING_TYPES { export const RGBA_0000 = 'rgba(0,0,0,0)'; export const SPATIAL_FILTERS_LAYER_ID = 'SPATIAL_FILTERS_LAYER_ID'; + +export enum INITIAL_LOCATION { + LAST_SAVED_LOCATION = 'LAST_SAVED_LOCATION', + FIXED_LOCATION = 'FIXED_LOCATION', + BROWSER_LOCATION = 'BROWSER_LOCATION', +} diff --git a/x-pack/plugins/maps/public/actions/map_actions.d.ts b/x-pack/plugins/maps/public/actions/map_actions.d.ts index 38c56405787eb..3bda29964a9a1 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.d.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.d.ts @@ -13,6 +13,7 @@ import { MapFilters, MapCenterAndZoom, MapRefreshConfig, + MapExtent, } from '../../common/descriptor_types'; import { MapSettings } from '../reducers/map'; @@ -34,6 +35,9 @@ export function updateSourceProp( ): void; export function setGotoWithCenter(config: MapCenterAndZoom): AnyAction; +export function setGotoWithBounds(config: MapExtent): AnyAction; + +export function fitToDataBounds(): AnyAction; export function replaceLayerList(layerList: unknown[]): AnyAction; @@ -72,7 +76,7 @@ export function trackMapSettings(): AnyAction; export function updateMapSetting( settingKey: string, - settingValue: string | boolean | number + settingValue: string | boolean | number | object ): AnyAction; export function cloneLayer(layerId: string): AnyAction; diff --git a/x-pack/plugins/maps/public/actions/map_actions.js b/x-pack/plugins/maps/public/actions/map_actions.js index da6ba6b481054..ea2602397702b 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.js +++ b/x-pack/plugins/maps/public/actions/map_actions.js @@ -19,6 +19,7 @@ import { getOpenTooltips, getQuery, getDataRequestDescriptor, + getFittableLayers, } from '../selectors/map_selectors'; import { FLYOUT_STATE } from '../reducers/ui'; @@ -567,6 +568,55 @@ export function fitToLayerExtent(layerId) { }; } +export function fitToDataBounds() { + return async function(dispatch, getState) { + const layerList = getFittableLayers(getState()); + + if (!layerList.length) { + return; + } + + const dataFilters = getDataFilters(getState()); + const boundsPromises = layerList.map(async layer => { + return layer.getBounds(dataFilters); + }); + + const bounds = await Promise.all(boundsPromises); + const corners = []; + for (let i = 0; i < bounds.length; i++) { + const b = bounds[i]; + + //filter out undefined bounds (uses Infinity due to turf responses) + + if ( + b.minLon === Infinity || + b.maxLon === Infinity || + b.minLat === -Infinity || + b.maxLat === -Infinity + ) { + continue; + } + + corners.push([b.minLon, b.minLat]); + corners.push([b.maxLon, b.maxLat]); + } + + if (!corners.length) { + return; + } + + const turfUnionBbox = turf.bbox(turf.multiPoint(corners)); + const dataBounds = { + minLon: turfUnionBbox[0], + minLat: turfUnionBbox[1], + maxLon: turfUnionBbox[2], + maxLat: turfUnionBbox[3], + }; + + dispatch(setGotoWithBounds(dataBounds)); + }; +} + export function setGotoWithBounds(bounds) { return { type: SET_GOTO, 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/gis_map/index.d.ts b/x-pack/plugins/maps/public/connected_components/gis_map/index.d.ts index 92d92dfbd142d..7edc51d9d78b3 100644 --- a/x-pack/plugins/maps/public/connected_components/gis_map/index.d.ts +++ b/x-pack/plugins/maps/public/connected_components/gis_map/index.d.ts @@ -9,7 +9,11 @@ import { Filter } from 'src/plugins/data/public'; import { RenderToolTipContent } from '../../layers/tooltips/tooltip_property'; -export const GisMap: React.ComponentType<{ +declare const GisMap: React.ComponentType<{ addFilters: ((filters: Filter[]) => void) | null; renderTooltipContent?: RenderToolTipContent; }>; + +export { GisMap }; +// eslint-disable-next-line import/no-default-export +export default GisMap; diff --git a/x-pack/plugins/maps/public/connected_components/gis_map/index.js b/x-pack/plugins/maps/public/connected_components/gis_map/index.js index f8769d0bb898a..78944fd470c0d 100644 --- a/x-pack/plugins/maps/public/connected_components/gis_map/index.js +++ b/x-pack/plugins/maps/public/connected_components/gis_map/index.js @@ -5,7 +5,7 @@ */ import { connect } from 'react-redux'; -import { GisMap } from './view'; +import { GisMap as UnconnectedGisMap } from './view'; import { exitFullScreen } from '../../actions/ui_actions'; import { getFlyoutDisplay, getIsFullScreen } from '../../selectors/ui_selectors'; import { triggerRefreshTimer, cancelAllInFlightRequests } from '../../actions/map_actions'; @@ -42,5 +42,6 @@ function mapDispatchToProps(dispatch) { }; } -const connectedGisMap = connect(mapStateToProps, mapDispatchToProps)(GisMap); -export { connectedGisMap as GisMap }; +const connectedGisMap = connect(mapStateToProps, mapDispatchToProps)(UnconnectedGisMap); +export { connectedGisMap as GisMap }; // GisMap is pulled in by name by the Maps-app itself +export default connectedGisMap; //lazy-loading in the embeddable requires default export diff --git a/x-pack/plugins/maps/public/connected_components/gis_map/view.js b/x-pack/plugins/maps/public/connected_components/gis_map/view.js index 6eb173a001d01..ca4b062ee7273 100644 --- a/x-pack/plugins/maps/public/connected_components/gis_map/view.js +++ b/x-pack/plugins/maps/public/connected_components/gis_map/view.js @@ -22,6 +22,7 @@ import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; import { FLYOUT_STATE } from '../../reducers/ui'; import { MapSettingsPanel } from '../map_settings_panel'; +import { registerLayerWizards } from '../../layers/load_layer_wizards'; const RENDER_COMPLETE_EVENT = 'renderComplete'; @@ -36,6 +37,7 @@ export class GisMap extends Component { this._isMounted = true; this._isInitalLoadRenderTimerStarted = false; this._setRefreshTimer(); + registerLayerWizards(); } componentDidUpdate() { diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts b/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts new file mode 100644 index 0000000000000..30e3b9b46916b --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts @@ -0,0 +1,45 @@ +/* + * 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 { INITIAL_LOCATION } from '../../../../common/constants'; +import { Goto, MapCenterAndZoom } from '../../../../common/descriptor_types'; +import { MapSettings } from '../../../reducers/map'; + +export async function getInitialView( + goto: Goto | null, + settings: MapSettings +): Promise { + if (settings.initialLocation === INITIAL_LOCATION.FIXED_LOCATION) { + return { + lat: settings.fixedLocation.lat, + lon: settings.fixedLocation.lon, + zoom: settings.fixedLocation.zoom, + }; + } + + if (settings.initialLocation === INITIAL_LOCATION.BROWSER_LOCATION) { + return await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + // success callback + pos => { + resolve({ + lat: pos.coords.latitude, + lon: pos.coords.longitude, + zoom: settings.browserLocation.zoom, + }); + }, + // error callback + () => { + // eslint-disable-next-line no-console + console.warn('Unable to fetch browser location for initial map location'); + resolve(null); + } + ); + }); + } + + return goto && goto.center ? goto.center : null; +} diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/index.js b/x-pack/plugins/maps/public/connected_components/map/mb/index.js index f8daf0804265b..e560e95b71f32 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/index.js +++ b/x-pack/plugins/maps/public/connected_components/map/mb/index.js @@ -40,7 +40,6 @@ function mapStateToProps(state = {}) { scrollZoom: getScrollZoom(state), disableInteractive: isInteractiveDisabled(state), disableTooltipControl: isTooltipControlDisabled(state), - disableTooltipControl: isTooltipControlDisabled(state), hideViewControl: isViewControlHidden(state), }; } diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/view.js b/x-pack/plugins/maps/public/connected_components/map/mb/view.js index 6bb5a4fed6e52..7afb326f42e02 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/view.js +++ b/x-pack/plugins/maps/public/connected_components/map/mb/view.js @@ -24,6 +24,7 @@ import sprites2 from '@elastic/maki/dist/sprite@2.png'; import { DrawControl } from './draw_control'; import { TooltipControl } from './tooltip_control'; import { clampToLatBounds, clampToLonBounds } from '../../../elasticsearch_geo_utils'; +import { getInitialView } from './get_initial_view'; import { getInjectedVarFunc } from '../../../kibana_services'; @@ -112,6 +113,7 @@ export class MBMapContainer extends React.Component { } async _createMbMapInstance() { + const initialView = await getInitialView(this.props.goto, this.props.settings); return new Promise(resolve => { const mbStyle = { version: 8, @@ -133,7 +135,6 @@ export class MBMapContainer extends React.Component { maxZoom: this.props.settings.maxZoom, minZoom: this.props.settings.minZoom, }; - const initialView = _.get(this.props.goto, 'center'); if (initialView) { options.zoom = initialView.zoom; options.center = { diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap new file mode 100644 index 0000000000000..641dd20a1a44a --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap @@ -0,0 +1,292 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` + + +
+ +
+
+ + + + + +
+`; + +exports[`should render browser location form when initialLocation is BROWSER_LOCATION 1`] = ` + + +
+ +
+
+ + + + + + + + +
+`; + +exports[`should render fixed location form when initialLocation is FIXED_LOCATION 1`] = ` + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts b/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts index 329fac28d7d2e..eaa49719059c5 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts @@ -10,13 +10,20 @@ import { FLYOUT_STATE } from '../../reducers/ui'; import { MapStoreState } from '../../reducers/store'; import { MapSettingsPanel } from './map_settings_panel'; import { rollbackMapSettings, updateMapSetting } from '../../actions/map_actions'; -import { getMapSettings, hasMapSettingsChanges } from '../../selectors/map_selectors'; +import { + getMapCenter, + getMapSettings, + getMapZoom, + hasMapSettingsChanges, +} from '../../selectors/map_selectors'; import { updateFlyout } from '../../actions/ui_actions'; function mapStateToProps(state: MapStoreState) { return { - settings: getMapSettings(state), + center: getMapCenter(state), hasMapSettingsChanges: hasMapSettingsChanges(state), + settings: getMapSettings(state), + zoom: getMapZoom(state), }; } @@ -29,7 +36,7 @@ function mapDispatchToProps(dispatch: Dispatch) { keepChanges: () => { dispatch(updateFlyout(FLYOUT_STATE.NONE)); }, - updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => { + updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => { dispatch(updateMapSetting(settingKey, settingValue)); }, }; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx index a89f4461fff06..66b979869416d 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx @@ -20,21 +20,26 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MapSettings } from '../../reducers/map'; import { NavigationPanel } from './navigation_panel'; import { SpatialFiltersPanel } from './spatial_filters_panel'; +import { MapCenter } from '../../../common/descriptor_types'; interface Props { cancelChanges: () => void; + center: MapCenter; hasMapSettingsChanges: boolean; keepChanges: () => void; settings: MapSettings; - updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => void; + updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => void; + zoom: number; } export function MapSettingsPanel({ cancelChanges, + center, hasMapSettingsChanges, keepChanges, settings, updateMapSetting, + zoom, }: Props) { // TODO move common text like Cancel and Close to common i18n translation const closeBtnLabel = hasMapSettingsChanges @@ -60,7 +65,12 @@ export function MapSettingsPanel({
- +
diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx new file mode 100644 index 0000000000000..d785a30324e4e --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 { shallow } from 'enzyme'; + +import { NavigationPanel } from './navigation_panel'; +import { getDefaultMapSettings } from '../../reducers/default_map_settings'; +import { INITIAL_LOCATION } from '../../../common/constants'; + +const defaultProps = { + center: { lat: 0, lon: 0 }, + settings: getDefaultMapSettings(), + updateMapSetting: () => {}, + zoom: 0, +}; + +test('should render', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render fixed location form when initialLocation is FIXED_LOCATION', async () => { + const settings = { + ...defaultProps.settings, + initialLocation: INITIAL_LOCATION.FIXED_LOCATION, + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render browser location form when initialLocation is BROWSER_LOCATION', async () => { + const settings = { + ...defaultProps.settings, + initialLocation: INITIAL_LOCATION.BROWSER_LOCATION, + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx index ed83e838f44f6..0e12f20dd9a7a 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx @@ -4,25 +4,198 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import React, { ChangeEvent } from 'react'; +import { + EuiButtonEmpty, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPanel, + EuiRadioGroup, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { MapSettings } from '../../reducers/map'; import { ValidatedDualRange, Value } from '../../../../../../src/plugins/kibana_react/public'; -import { MAX_ZOOM, MIN_ZOOM } from '../../../common/constants'; +import { INITIAL_LOCATION, MAX_ZOOM, MIN_ZOOM } from '../../../common/constants'; +import { MapCenter } from '../../../common/descriptor_types'; +// @ts-ignore +import { ValidatedRange } from '../../components/validated_range'; interface Props { + center: MapCenter; settings: MapSettings; - updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => void; + updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => void; + zoom: number; } -export function NavigationPanel({ settings, updateMapSetting }: Props) { +const initialLocationOptions = [ + { + id: INITIAL_LOCATION.LAST_SAVED_LOCATION, + label: i18n.translate('xpack.maps.mapSettingsPanel.lastSavedLocationLabel', { + defaultMessage: 'Map location at save', + }), + }, + { + id: INITIAL_LOCATION.FIXED_LOCATION, + label: i18n.translate('xpack.maps.mapSettingsPanel.fixedLocationLabel', { + defaultMessage: 'Fixed location', + }), + }, + { + id: INITIAL_LOCATION.BROWSER_LOCATION, + label: i18n.translate('xpack.maps.mapSettingsPanel.browserLocationLabel', { + defaultMessage: 'Browser location', + }), + }, +]; + +export function NavigationPanel({ center, settings, updateMapSetting, zoom }: Props) { const onZoomChange = (value: Value) => { - updateMapSetting('minZoom', Math.max(MIN_ZOOM, parseInt(value[0] as string, 10))); - updateMapSetting('maxZoom', Math.min(MAX_ZOOM, parseInt(value[1] as string, 10))); + const minZoom = Math.max(MIN_ZOOM, parseInt(value[0] as string, 10)); + const maxZoom = Math.min(MAX_ZOOM, parseInt(value[1] as string, 10)); + updateMapSetting('minZoom', minZoom); + updateMapSetting('maxZoom', maxZoom); + + // ensure fixed zoom and browser zoom stay within defined min/max + if (settings.fixedLocation.zoom < minZoom) { + onFixedZoomChange(minZoom); + } else if (settings.fixedLocation.zoom > maxZoom) { + onFixedZoomChange(maxZoom); + } + + if (settings.browserLocation.zoom < minZoom) { + onBrowserZoomChange(minZoom); + } else if (settings.browserLocation.zoom > maxZoom) { + onBrowserZoomChange(maxZoom); + } + }; + + const onInitialLocationChange = (optionId: string): void => { + updateMapSetting('initialLocation', optionId); + }; + + const onFixedLatChange = (event: ChangeEvent) => { + let value = parseFloat(event.target.value); + if (isNaN(value)) { + value = 0; + } else if (value < -90) { + value = -90; + } else if (value > 90) { + value = 90; + } + updateMapSetting('fixedLocation', { ...settings.fixedLocation, lat: value }); + }; + + const onFixedLonChange = (event: ChangeEvent) => { + let value = parseFloat(event.target.value); + if (isNaN(value)) { + value = 0; + } else if (value < -180) { + value = -180; + } else if (value > 180) { + value = 180; + } + updateMapSetting('fixedLocation', { ...settings.fixedLocation, lon: value }); + }; + + const onFixedZoomChange = (value: number) => { + updateMapSetting('fixedLocation', { ...settings.fixedLocation, zoom: value }); + }; + + const onBrowserZoomChange = (value: number) => { + updateMapSetting('browserLocation', { zoom: value }); + }; + + const useCurrentView = () => { + updateMapSetting('fixedLocation', { + lat: center.lat, + lon: center.lon, + zoom: Math.round(zoom), + }); }; + function renderInitialLocationInputs() { + if (settings.initialLocation === INITIAL_LOCATION.LAST_SAVED_LOCATION) { + return null; + } + + const zoomFormRow = ( + + + + ); + + if (settings.initialLocation === INITIAL_LOCATION.BROWSER_LOCATION) { + return zoomFormRow; + } + + return ( + <> + + + + + + + {zoomFormRow} + + + + + + + + + ); + } + return ( @@ -50,6 +223,19 @@ export function NavigationPanel({ settings, updateMapSetting }: Props) { allowEmptyRange={false} compressed /> + + + + + {renderInitialLocationInputs()} ); } diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap new file mode 100644 index 0000000000000..3407bcfd4f845 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Must render zoom tools 1`] = ` + + + + + + + + +`; + +exports[`Must zoom tools and draw filter tools 1`] = ` + + + + + + + + + + + +`; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss index 2754a3e204263..e92e89b170370 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/_index.scss @@ -12,6 +12,7 @@ // sass-lint:disable-block no-important background-color: $euiColorEmptyShade !important; pointer-events: all; + position: relative; &:enabled, &:enabled:hover, diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx new file mode 100644 index 0000000000000..0b168badb2f3f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx @@ -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 React from 'react'; + +import { EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ILayer } from '../../../layers/layer'; + +interface Props { + layerList: ILayer[]; + fitToBounds: () => void; +} + +export const FitToData: React.FunctionComponent = ({ layerList, fitToBounds }: Props) => { + if (layerList.length === 0) { + return null; + } + + return ( + + ); +}; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.ts b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.ts new file mode 100644 index 0000000000000..d6ded62f2f480 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.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 { AnyAction, Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { MapStoreState } from '../../../reducers/store'; +import { fitToDataBounds } from '../../../actions/map_actions'; +import { getFittableLayers } from '../../../selectors/map_selectors'; +import { FitToData } from './fit_to_data'; + +function mapStateToProps(state: MapStoreState) { + return { + layerList: getFittableLayers(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + fitToBounds: () => { + dispatch(fitToDataBounds()); + }, + }; +} + +const connectedFitToData = connect(mapStateToProps, mapDispatchToProps)(FitToData); +export { connectedFitToData as FitToData }; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js index 32668be8f8f67..a4f85163512f7 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.js @@ -8,6 +8,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { SetViewControl } from './set_view_control'; import { ToolsControl } from './tools_control'; +import { FitToData } from './fit_to_data'; export class ToolbarOverlay extends React.Component { _renderToolsControl() { @@ -36,6 +37,10 @@ export class ToolbarOverlay extends React.Component { + + + + {this._renderToolsControl()} ); diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx new file mode 100644 index 0000000000000..c03aa1e098540 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +// @ts-ignore +import { ToolbarOverlay } from './toolbar_overlay'; + +test('Must render zoom tools', async () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('Must zoom tools and draw filter tools', async () => { + const component = shallow( {}} geoFields={['coordinates']} />); + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap index b8c652909408a..388712e1ebcca 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/__snapshots__/toc_entry_actions_popover.test.tsx.snap @@ -72,7 +72,7 @@ exports[`TOCEntryActionsPopover is rendered 1`] = ` "disabled": false, "icon": , "name": "Fit to data", "onClick": [Function], @@ -200,7 +200,7 @@ exports[`TOCEntryActionsPopover should disable fit to data when supportsFitToBou "disabled": true, "icon": , "name": "Fit to data", "onClick": [Function], @@ -328,7 +328,7 @@ exports[`TOCEntryActionsPopover should not show edit actions in read only mode 1 "disabled": false, "icon": , "name": "Fit to data", "onClick": [Function], 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 d628cca61de11..dfc93c29263ee 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() { @@ -137,7 +137,7 @@ export class TOCEntryActionsPopover extends Component { name: i18n.translate('xpack.maps.layerTocActions.fitToDataTitle', { defaultMessage: 'Fit to data', }), - icon: , + icon: , 'data-test-subj': 'fitToBoundsButton', toolTipContent: this.state.supportsFitToBounds ? null diff --git a/x-pack/plugins/maps/public/embeddable/lazy/index.ts b/x-pack/plugins/maps/public/embeddable/lazy/index.ts new file mode 100644 index 0000000000000..7475d56ee076f --- /dev/null +++ b/x-pack/plugins/maps/public/embeddable/lazy/index.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +// These are map-dependencies of the embeddable. +// By lazy-loading these, the Maps-app can register the embeddable when the plugin mounts, without actually pulling all the code. + +// @ts-ignore +export * from '../../angular/services/gis_map_saved_object_loader'; +export * from '../map_embeddable'; +export * from '../../kibana_services'; +export * from '../../reducers/store'; +export * from '../../actions/map_actions'; +export * from '../../selectors/map_selectors'; +export * from '../../angular/get_initial_layers'; +export * from './../merge_input_with_saved_map'; diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index 467cf4727edb7..c3937ba4cdcbb 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -5,16 +5,16 @@ */ import _ from 'lodash'; -import React from 'react'; +import React, { Suspense, lazy } from 'react'; import { Provider } from 'react-redux'; import { render, unmountComponentAtNode } from 'react-dom'; import 'mapbox-gl/dist/mapbox-gl.css'; import { Subscription } from 'rxjs'; import { Unsubscribe } from 'redux'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { Embeddable, IContainer, - EmbeddableInput, EmbeddableOutput, } from '../../../../../src/plugins/embeddable/public'; import { APPLY_FILTER_TRIGGER } from '../../../../../src/plugins/ui_actions/public'; @@ -26,7 +26,6 @@ import { Query, RefreshInterval, } from '../../../../../src/plugins/data/public'; -import { GisMap } from '../connected_components/gis_map'; import { createMapStore, MapStore } from '../reducers/store'; import { MapSettings } from '../reducers/map'; import { @@ -43,7 +42,6 @@ import { setHiddenLayers, setMapSettings, } from '../actions/map_actions'; -import { MapCenterAndZoom } from '../../common/descriptor_types'; import { setReadOnly, setIsLayerTOCOpen, setOpenTOCDetails } from '../actions/ui_actions'; import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors'; import { @@ -56,36 +54,14 @@ import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants'; import { RenderToolTipContent } from '../layers/tooltips/tooltip_property'; import { getUiActions, getCoreI18n } from '../kibana_services'; -interface MapEmbeddableConfig { - editUrl?: string; - indexPatterns: IIndexPattern[]; - editable: boolean; - title?: string; - layerList: unknown[]; - settings?: MapSettings; -} - -export interface MapEmbeddableInput extends EmbeddableInput { - timeRange?: TimeRange; - filters: Filter[]; - query?: Query; - refreshConfig: RefreshInterval; - isLayerTOCOpen: boolean; - openTOCDetails?: string[]; - disableTooltipControl?: boolean; - disableInteractive?: boolean; - hideToolbarOverlay?: boolean; - hideLayerControl?: boolean; - hideViewControl?: boolean; - mapCenter?: MapCenterAndZoom; - hiddenLayers?: string[]; - hideFilterActions?: boolean; -} +import { MapEmbeddableInput, MapEmbeddableConfig } from './types'; +export { MapEmbeddableInput, MapEmbeddableConfig }; export interface MapEmbeddableOutput extends EmbeddableOutput { indexPatterns: IIndexPattern[]; } +const GisMap = lazy(() => import('../connected_components/gis_map')); export class MapEmbeddable extends Embeddable { type = MAP_SAVED_OBJECT_TYPE; @@ -254,10 +230,12 @@ export class MapEmbeddable extends Embeddable - + }> + + , this._domNode diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts index abfaba80c33d1..7e3a8387bed11 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable_factory.ts @@ -6,24 +6,67 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; +import { AnyAction } from 'redux'; import { IIndexPattern } from 'src/plugins/data/public'; -// @ts-ignore -import { getMapsSavedObjectLoader } from '../angular/services/gis_map_saved_object_loader'; -import { MapEmbeddable, MapEmbeddableInput } from './map_embeddable'; -import { getIndexPatternService, getHttp, getMapsCapabilities } from '../kibana_services'; import { + Embeddable, EmbeddableFactoryDefinition, IContainer, } from '../../../../../src/plugins/embeddable/public'; - +import '../index.scss'; import { createMapPath, MAP_SAVED_OBJECT_TYPE, APP_ICON } from '../../common/constants'; +import { MapStore, MapStoreState } from '../reducers/store'; +import { MapEmbeddableConfig, MapEmbeddableInput } from './types'; +import { MapEmbeddableOutput } from './map_embeddable'; +import { RenderToolTipContent } from '../layers/tooltips/tooltip_property'; +import { EventHandlers } from '../reducers/non_serializable_instances'; + +let whenModulesLoadedPromise: Promise; + +let getMapsSavedObjectLoader: any; +let MapEmbeddable: new ( + config: MapEmbeddableConfig, + initialInput: MapEmbeddableInput, + parent?: IContainer, + renderTooltipContent?: RenderToolTipContent, + eventHandlers?: EventHandlers +) => Embeddable; + +let getIndexPatternService: () => { + get: (id: string) => IIndexPattern | undefined; +}; +let getHttp: () => any; +let getMapsCapabilities: () => any; +let createMapStore: () => MapStore; +let addLayerWithoutDataSync: (layerDescriptor: unknown) => AnyAction; +let getQueryableUniqueIndexPatternIds: (state: MapStoreState) => string[]; +let getInitialLayers: (layerListJSON?: string, initialLayers?: unknown[]) => unknown[]; +let mergeInputWithSavedMap: any; + +async function waitForMapDependencies(): Promise { + if (typeof whenModulesLoadedPromise !== 'undefined') { + return whenModulesLoadedPromise; + } -import { createMapStore } from '../reducers/store'; -import { addLayerWithoutDataSync } from '../actions/map_actions'; -import { getQueryableUniqueIndexPatternIds } from '../selectors/map_selectors'; -import { getInitialLayers } from '../angular/get_initial_layers'; -import { mergeInputWithSavedMap } from './merge_input_with_saved_map'; -import '../index.scss'; + whenModulesLoadedPromise = new Promise(async resolve => { + ({ + // @ts-ignore + getMapsSavedObjectLoader, + getQueryableUniqueIndexPatternIds, + MapEmbeddable, + getIndexPatternService, + getHttp, + getMapsCapabilities, + createMapStore, + addLayerWithoutDataSync, + getInitialLayers, + mergeInputWithSavedMap, + } = await import('./lazy')); + + resolve(true); + }); + return whenModulesLoadedPromise; +} export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { type = MAP_SAVED_OBJECT_TYPE; @@ -36,6 +79,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { }; async isEditable() { + await waitForMapDependencies(); return getMapsCapabilities().save as boolean; } @@ -53,7 +97,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { async _getIndexPatterns(layerList: unknown[]): Promise { // Need to extract layerList from store to get queryable index pattern ids const store = createMapStore(); - let queryableIndexPatternIds; + let queryableIndexPatternIds: string[]; try { layerList.forEach((layerDescriptor: unknown) => { store.dispatch(addLayerWithoutDataSync(layerDescriptor)); @@ -69,6 +113,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { const promises = queryableIndexPatternIds.map(async indexPatternId => { try { + // @ts-ignore return await getIndexPatternService().get(indexPatternId); } catch (error) { // Unable to load index pattern, better to not throw error so map embeddable can render @@ -90,6 +135,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { input: MapEmbeddableInput, parent?: IContainer ) => { + await waitForMapDependencies(); const savedMap = await this._fetchSavedMap(savedObjectId); const layerList = getInitialLayers(savedMap.layerListJSON); const indexPatterns = await this._getIndexPatterns(layerList); @@ -129,6 +175,7 @@ export class MapEmbeddableFactory implements EmbeddableFactoryDefinition { }; create = async (input: MapEmbeddableInput, parent?: IContainer) => { + await waitForMapDependencies(); const layerList = getInitialLayers(); const indexPatterns = await this._getIndexPatterns(layerList); diff --git a/x-pack/plugins/maps/public/embeddable/types.ts b/x-pack/plugins/maps/public/embeddable/types.ts new file mode 100644 index 0000000000000..d287c431e50c2 --- /dev/null +++ b/x-pack/plugins/maps/public/embeddable/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 { IIndexPattern } from '../../../../../src/plugins/data/common/index_patterns'; +import { MapSettings } from '../reducers/map'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { EmbeddableInput } from '../../../../../src/plugins/embeddable/public/lib/embeddables'; +import { Filter, Query, RefreshInterval, TimeRange } from '../../../../../src/plugins/data/common'; +import { MapCenterAndZoom } from '../../common/descriptor_types'; + +export interface MapEmbeddableConfig { + editUrl?: string; + indexPatterns: IIndexPattern[]; + editable: boolean; + title?: string; + layerList: unknown[]; + settings?: MapSettings; +} + +export interface MapEmbeddableInput extends EmbeddableInput { + timeRange?: TimeRange; + filters: Filter[]; + query?: Query; + refreshConfig: RefreshInterval; + isLayerTOCOpen: boolean; + openTOCDetails?: string[]; + disableTooltipControl?: boolean; + disableInteractive?: boolean; + hideToolbarOverlay?: boolean; + hideLayerControl?: boolean; + hideViewControl?: boolean; + mapCenter?: MapCenterAndZoom; + hiddenLayers?: string[]; + hideFilterActions?: boolean; +} diff --git a/x-pack/plugins/maps/public/layers/blended_vector_layer.ts b/x-pack/plugins/maps/public/layers/blended_vector_layer.ts index 5c486200977d7..adf04b4155659 100644 --- a/x-pack/plugins/maps/public/layers/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/layers/blended_vector_layer.ts @@ -260,33 +260,40 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { prevDataRequest: this.getDataRequest(dataRequestId), nextMeta: searchFilters, }); - if (canSkipFetch) { - return; - } - - let isSyncClustered; - try { - syncContext.startLoading(dataRequestId, requestToken, searchFilters); - const searchSource = await this._documentSource.makeSearchSource(searchFilters, 0); - const resp = await searchSource.fetch(); - const maxResultWindow = await this._documentSource.getMaxResultWindow(); - isSyncClustered = resp.hits.total > maxResultWindow; - syncContext.stopLoading(dataRequestId, requestToken, { isSyncClustered }, searchFilters); - } catch (error) { - if (!(error instanceof DataRequestAbortError)) { - syncContext.onLoadError(dataRequestId, requestToken, error.message); - } - return; - } let activeSource; let activeStyle; - if (isSyncClustered) { - activeSource = this._clusterSource; - activeStyle = this._clusterStyle; + if (canSkipFetch) { + // Even when source fetch is skipped, need to call super._syncData to sync StyleMeta and formatters + if (this._isClustered) { + activeSource = this._clusterSource; + activeStyle = this._clusterStyle; + } else { + activeSource = this._documentSource; + activeStyle = this._documentStyle; + } } else { - activeSource = this._documentSource; - activeStyle = this._documentStyle; + let isSyncClustered; + try { + syncContext.startLoading(dataRequestId, requestToken, searchFilters); + const searchSource = await this._documentSource.makeSearchSource(searchFilters, 0); + const resp = await searchSource.fetch(); + const maxResultWindow = await this._documentSource.getMaxResultWindow(); + isSyncClustered = resp.hits.total > maxResultWindow; + syncContext.stopLoading(dataRequestId, requestToken, { isSyncClustered }, searchFilters); + } catch (error) { + if (!(error instanceof DataRequestAbortError)) { + syncContext.onLoadError(dataRequestId, requestToken, error.message); + } + return; + } + if (isSyncClustered) { + activeSource = this._clusterSource; + activeStyle = this._clusterStyle; + } else { + activeSource = this._documentSource; + activeStyle = this._documentStyle; + } } super._syncData(syncContext, activeSource, activeStyle); diff --git a/x-pack/plugins/maps/public/layers/fields/es_agg_field.ts b/x-pack/plugins/maps/public/layers/fields/es_agg_field.ts index 34f7dd4b9578f..4a3ac6390c5a7 100644 --- a/x-pack/plugins/maps/public/layers/fields/es_agg_field.ts +++ b/x-pack/plugins/maps/public/layers/fields/es_agg_field.ts @@ -125,8 +125,8 @@ export class ESAggField implements IESAggField { return this._esDocField ? this._esDocField.getOrdinalFieldMetaRequest() : null; } - async getCategoricalFieldMetaRequest(): Promise { - return this._esDocField ? this._esDocField.getCategoricalFieldMetaRequest() : null; + async getCategoricalFieldMetaRequest(size: number): Promise { + return this._esDocField ? this._esDocField.getCategoricalFieldMetaRequest(size) : null; } } diff --git a/x-pack/plugins/maps/public/layers/fields/es_doc_field.ts b/x-pack/plugins/maps/public/layers/fields/es_doc_field.ts index b7647d881fcf6..670b3ba32888b 100644 --- a/x-pack/plugins/maps/public/layers/fields/es_doc_field.ts +++ b/x-pack/plugins/maps/public/layers/fields/es_doc_field.ts @@ -7,7 +7,6 @@ import { FIELD_ORIGIN } from '../../../common/constants'; import { ESTooltipProperty } from '../tooltips/es_tooltip_property'; import { ITooltipProperty, TooltipProperty } from '../tooltips/tooltip_property'; -import { COLOR_PALETTE_MAX_SIZE } from '../../../common/constants'; import { indexPatterns } from '../../../../../../src/plugins/data/public'; import { IFieldType } from '../../../../../../src/plugins/data/public'; import { IField, AbstractField } from './field'; @@ -89,16 +88,16 @@ export class ESDocField extends AbstractField implements IField { }; } - async getCategoricalFieldMetaRequest(): Promise { + async getCategoricalFieldMetaRequest(size: number): Promise { const indexPatternField = await this._getIndexPatternField(); - if (!indexPatternField) { + if (!indexPatternField || size <= 0) { return null; } // TODO remove local typing once Kibana has figured out a core place for Elasticsearch aggregation request types // https://github.com/elastic/kibana/issues/60102 const topTerms: { size: number; script?: unknown; field?: string } = { - size: COLOR_PALETTE_MAX_SIZE - 1, // need additional color for the "other"-value + size: size - 1, // need additional color for the "other"-value }; if (indexPatternField.scripted) { topTerms.script = { diff --git a/x-pack/plugins/maps/public/layers/fields/field.ts b/x-pack/plugins/maps/public/layers/fields/field.ts index b431be4aa6cb8..539d0ab4d6ade 100644 --- a/x-pack/plugins/maps/public/layers/fields/field.ts +++ b/x-pack/plugins/maps/public/layers/fields/field.ts @@ -19,7 +19,7 @@ export interface IField { getOrigin(): FIELD_ORIGIN; isValid(): boolean; getOrdinalFieldMetaRequest(): Promise; - getCategoricalFieldMetaRequest(): Promise; + getCategoricalFieldMetaRequest(size: number): Promise; } export class AbstractField implements IField { @@ -76,7 +76,7 @@ export class AbstractField implements IField { return null; } - async getCategoricalFieldMetaRequest(): Promise { + async getCategoricalFieldMetaRequest(size: number): Promise { return null; } } diff --git a/x-pack/plugins/maps/public/layers/styles/color_utils.js b/x-pack/plugins/maps/public/layers/styles/color_utils.js index 23b61b07bf871..ec90ea08adeae 100644 --- a/x-pack/plugins/maps/public/layers/styles/color_utils.js +++ b/x-pack/plugins/maps/public/layers/styles/color_utils.js @@ -9,7 +9,6 @@ import tinycolor from 'tinycolor2'; import chroma from 'chroma-js'; import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; import { ColorGradient } from './components/color_gradient'; -import { COLOR_PALETTE_MAX_SIZE } from '../../../common/constants'; import { vislibColorMaps } from '../../../../../../src/plugins/charts/public'; const GRADIENT_INTERVALS = 8; @@ -120,7 +119,15 @@ export function getLinearGradient(colorStrings) { const COLOR_PALETTES_CONFIGS = [ { id: 'palette_0', - colors: DEFAULT_FILL_COLORS.slice(0, COLOR_PALETTE_MAX_SIZE), + colors: euiPaletteColorBlind(), + }, + { + id: 'palette_20', + colors: euiPaletteColorBlind(2), + }, + { + id: 'palette_30', + colors: euiPaletteColorBlind(3), }, ]; @@ -133,7 +140,7 @@ export const COLOR_PALETTES = COLOR_PALETTES_CONFIGS.map(palette => { const paletteDisplay = palette.colors.map(color => { const style = { backgroundColor: color, - width: '10%', + width: `${100 / palette.colors.length}%`, position: 'relative', height: '100%', display: 'inline-block', diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js index e671f00b78381..0afc784c482c5 100644 --- a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -90,6 +90,11 @@ export class DynamicColorProperty extends DynamicStyleProperty { return this._options.type === COLOR_MAP_TYPE.CATEGORICAL; } + getNumberOfCategories() { + const colors = getColorPalette(this._options.colorCategory); + return colors ? colors.length : 0; + } + supportsMbFeatureState() { return true; } diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts index 72ca7def73908..b53623ab52edb 100644 --- a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts @@ -23,6 +23,7 @@ export interface IDynamicStyleProperty extends IStyleProperty { getFieldOrigin(): FIELD_ORIGIN | undefined; getRangeFieldMeta(): RangeFieldMeta; getCategoryFieldMeta(): CategoryFieldMeta; + getNumberOfCategories(): number; isFieldMetaEnabled(): boolean; isOrdinal(): boolean; supportsFieldMeta(): boolean; diff --git a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index 8cef78f9a8f21..451a79dd3864a 100644 --- a/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -7,12 +7,7 @@ import _ from 'lodash'; import { AbstractStyleProperty } from './style_property'; import { DEFAULT_SIGMA } from '../vector_style_defaults'; -import { - COLOR_PALETTE_MAX_SIZE, - STYLE_TYPE, - SOURCE_META_ID_ORIGIN, - FIELD_ORIGIN, -} from '../../../../../common/constants'; +import { STYLE_TYPE, SOURCE_META_ID_ORIGIN, FIELD_ORIGIN } from '../../../../../common/constants'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; import { CategoricalLegend } from './components/categorical_legend'; @@ -120,6 +115,10 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return false; } + getNumberOfCategories() { + return 0; + } + hasOrdinalBreaks() { return false; } @@ -149,7 +148,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { if (this.isOrdinal()) { return this._field.getOrdinalFieldMetaRequest(); } else if (this.isCategorical()) { - return this._field.getCategoricalFieldMetaRequest(); + return this._field.getCategoricalFieldMetaRequest(this.getNumberOfCategories()); } else { return null; } @@ -190,7 +189,8 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } pluckCategoricalStyleMetaFromFeatures(features) { - if (!this.isCategorical()) { + const size = this.getNumberOfCategories(); + if (!this.isCategorical() || size <= 0) { return null; } @@ -217,7 +217,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { ordered.sort((a, b) => { return b.count - a.count; }); - const truncated = ordered.slice(0, COLOR_PALETTE_MAX_SIZE); + const truncated = ordered.slice(0, size); return { categories: truncated, }; diff --git a/x-pack/plugins/maps/public/layers/vector_layer.js b/x-pack/plugins/maps/public/layers/vector_layer.js index 582e34bce2e98..74ddf11c6beb4 100644 --- a/x-pack/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/plugins/maps/public/layers/vector_layer.js @@ -464,7 +464,7 @@ export class VectorLayer extends AbstractLayer { } const dynamicStyleFields = dynamicStyleProps.map(dynamicStyleProp => { - return dynamicStyleProp.getField().getName(); + return `${dynamicStyleProp.getField().getName()}${dynamicStyleProp.getNumberOfCategories()}`; }); const nextMeta = { diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index bdcd14ea98782..21bff95731580 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -36,11 +36,10 @@ import { import { featureCatalogueEntry } from './feature_catalogue_entry'; // @ts-ignore import { getMapsVisTypeAlias } from './maps_vis_type_alias'; -import { registerLayerWizards } from './layers/load_layer_wizards'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { VisualizationsSetup } from '../../../../src/plugins/visualizations/public'; import { MAP_SAVED_OBJECT_TYPE } from '../common/constants'; -import { MapEmbeddableFactory } from './embeddable'; +import { MapEmbeddableFactory } from './embeddable/map_embeddable_factory'; import { EmbeddableSetup } from '../../../../src/plugins/embeddable/public'; export interface MapsPluginSetupDependencies { @@ -85,7 +84,6 @@ export const bindStartCoreAndPlugins = (core: CoreStart, plugins: any) => { setUiActions(plugins.uiActions); setNavigation(plugins.navigation); setCoreI18n(core.i18n); - registerLayerWizards(); }; /** diff --git a/x-pack/plugins/maps/public/reducers/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/default_map_settings.ts index fe21b37434edd..9c9b814ae6add 100644 --- a/x-pack/plugins/maps/public/reducers/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/default_map_settings.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MAX_ZOOM, MIN_ZOOM } from '../../common/constants'; +import { INITIAL_LOCATION, MAX_ZOOM, MIN_ZOOM } from '../../common/constants'; import { MapSettings } from './map'; export function getDefaultMapSettings(): MapSettings { return { + initialLocation: INITIAL_LOCATION.LAST_SAVED_LOCATION, + fixedLocation: { lat: 0, lon: 0, zoom: 2 }, + browserLocation: { zoom: 2 }, maxZoom: MAX_ZOOM, minZoom: MIN_ZOOM, showSpatialFilters: true, diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index be0700d4bdd6d..20e1dc1035e19 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -15,6 +15,7 @@ import { MapRefreshConfig, TooltipState, } from '../../common/descriptor_types'; +import { INITIAL_LOCATION } from '../../common/constants'; import { Filter, TimeRange } from '../../../../../src/plugins/data/public'; export type MapContext = { @@ -40,6 +41,15 @@ export type MapContext = { }; export type MapSettings = { + initialLocation: INITIAL_LOCATION; + fixedLocation: { + lat: number; + lon: number; + zoom: number; + }; + browserLocation: { + zoom: number; + }; maxZoom: number; minZoom: number; showSpatialFilters: boolean; diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.d.ts b/x-pack/plugins/maps/public/selectors/map_selectors.d.ts index bc881d06f62ce..9caa151db6d5a 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.d.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.d.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AnyAction } from 'redux'; import { MapCenter } from '../../common/descriptor_types'; import { MapStoreState } from '../reducers/store'; import { MapSettings } from '../reducers/map'; import { IVectorLayer } from '../layers/vector_layer'; +import { ILayer } from '../layers/layer'; export function getHiddenLayerIds(state: MapStoreState): string[]; @@ -25,3 +25,6 @@ export function hasMapSettingsChanges(state: MapStoreState): boolean; export function isUsingSearch(state: MapStoreState): boolean; export function getSpatialFiltersLayer(state: MapStoreState): IVectorLayer; + +export function getLayerList(state: MapStoreState): ILayer[]; +export function getFittableLayers(state: MapStoreState): ILayer[]; diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.js b/x-pack/plugins/maps/public/selectors/map_selectors.js index f43c92d4c9945..38a862973623a 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.js +++ b/x-pack/plugins/maps/public/selectors/map_selectors.js @@ -251,6 +251,19 @@ export const getLayerList = createSelector( } ); +export const getFittableLayers = createSelector(getLayerList, layerList => { + return layerList.filter(layer => { + //These are the only layer-types that implement bounding-box retrieval reliably + //This will _not_ work if Maps will allow register custom layer types + const isFittable = + layer.getType() === LAYER_TYPE.VECTOR || + layer.getType() === LAYER_TYPE.BLENDED_VECTOR || + layer.getType() === LAYER_TYPE.HEATMAP; + + return isFittable && layer.isVisible(); + }); +}); + export const getHiddenLayerIds = createSelector(getLayerListRaw, layers => layers.filter(layer => !layer.visible).map(layer => layer.id) ); diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 038f61b3a33b7..e9d4aff3484b1 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 9cc42a4df2f66..decd1275fe884 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 e6b0b6b4270bd..0000000000000 --- 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 59cf98e5ffd71..0000000000000 --- 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 aa1dbf92b0677..231854cd264c2 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 0000000000000..b524e18102a95 --- /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 75c65ebaa0f50..ec19fe18bd324 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/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index a5b301902cc75..aeb774a224021 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, FC } from 'react'; +import { isEqual } from 'lodash'; +import React, { memo, useEffect, FC } from 'react'; import { i18n } from '@kbn/i18n'; @@ -50,132 +51,154 @@ function isWithHeader(arg: any): arg is PropsWithHeader { type Props = PropsWithHeader | PropsWithoutHeader; -export const DataGrid: FC = props => { - const { - columns, - dataTestSubj, - errorMessage, - invalidSortingColumnns, - noDataMessage, - onChangeItemsPerPage, - onChangePage, - onSort, - pagination, - setVisibleColumns, - renderCellValue, - rowCount, - sortingColumns, - status, - tableItems: data, - toastNotifications, - visibleColumns, - } = props; - - useEffect(() => { - if (invalidSortingColumnns.length > 0) { - invalidSortingColumnns.forEach(columnId => { - toastNotifications.addDanger( - i18n.translate('xpack.ml.dataGrid.invalidSortingColumnError', { - defaultMessage: `The column '{columnId}' cannot be used for sorting.`, - values: { columnId }, - }) - ); - }); - } - }, [invalidSortingColumnns, toastNotifications]); - - if (status === INDEX_STATUS.LOADED && data.length === 0) { - return ( -
- {isWithHeader(props) && } - -

- {i18n.translate('xpack.ml.dataGrid.IndexNoDataCalloutBody', { - defaultMessage: - 'The query for the index returned no results. Please make sure you have sufficient permissions, the index contains documents and your query is not too restrictive.', +export const DataGrid: FC = memo( + props => { + const { + columns, + dataTestSubj, + errorMessage, + invalidSortingColumnns, + noDataMessage, + onChangeItemsPerPage, + onChangePage, + onSort, + pagination, + setVisibleColumns, + renderCellValue, + rowCount, + sortingColumns, + status, + tableItems: data, + toastNotifications, + visibleColumns, + } = props; + + useEffect(() => { + if (invalidSortingColumnns.length > 0) { + invalidSortingColumnns.forEach(columnId => { + toastNotifications.addDanger( + i18n.translate('xpack.ml.dataGrid.invalidSortingColumnError', { + defaultMessage: `The column '{columnId}' cannot be used for sorting.`, + values: { columnId }, + }) + ); + }); + } + }, [invalidSortingColumnns, toastNotifications]); + + if (status === INDEX_STATUS.LOADED && data.length === 0) { + return ( +

+ {isWithHeader(props) && } + - -
- ); - } + color="primary" + > +

+ {i18n.translate('xpack.ml.dataGrid.IndexNoDataCalloutBody', { + defaultMessage: + 'The query for the index returned no results. Please make sure you have sufficient permissions, the index contains documents and your query is not too restrictive.', + })} +

+
+
+ ); + } - if (noDataMessage !== '') { - return ( -
- {isWithHeader(props) && } - -

{noDataMessage}

-
-
- ); - } - - return ( -
- {isWithHeader(props) && ( - - - - - - - {(copy: () => void) => ( - - )} - - - - )} - {status === INDEX_STATUS.ERROR && ( -
+ if (noDataMessage !== '') { + return ( +
+ {isWithHeader(props) && } - - {errorMessage} - +

{noDataMessage}

-
- )} - -
- ); -}; + ); + } + + return ( +
+ {isWithHeader(props) && ( + + + + + + + {(copy: () => void) => ( + + )} + + + + )} + {status === INDEX_STATUS.ERROR && ( +
+ + + {errorMessage} + + + +
+ )} + +
+ ); + }, + (prevProps, nextProps) => isEqual(pickProps(prevProps), pickProps(nextProps)) +); + +function pickProps(props: Props) { + return [ + props.columns, + props.dataTestSubj, + props.errorMessage, + props.invalidSortingColumnns, + props.noDataMessage, + props.pagination, + props.rowCount, + props.sortingColumns, + props.status, + props.tableItems, + props.visibleColumns, + ...(isWithHeader(props) + ? [props.copyToClipboard, props.copyToClipboardDescription, props.title] + : []), + ]; +} 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 381e5e75356c1..f709c161bef17 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.js deleted file mode 100644 index 4d2ab01e2a054..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js +++ /dev/null @@ -1,47 +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 { PropTypes } from 'prop-types'; -import { EuiBadge } from '@elastic/eui'; -import { tabColor } from '../../../../../common/util/group_color_utils'; -import { i18n } from '@kbn/i18n'; - -export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId }) { - const color = isGroup ? tabColor(id) : 'hollow'; - let props = { color }; - let jobCount; - - if (icon === true) { - props = { - ...props, - iconType: 'cross', - iconSide: 'right', - onClick: () => removeId(id), - onClickAriaLabel: 'Remove id', - }; - } - - if (numJobs !== undefined) { - jobCount = i18n.translate('xpack.ml.jobSelector.selectedGroupJobs', { - defaultMessage: `({jobsCount, plural, one {# job} other {# jobs}})`, - values: { jobsCount: numJobs }, - }); - } - - return ( - - {`${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_badge/job_selector_badge.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx new file mode 100644 index 0000000000000..b2cae278c0e77 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { tabColor } from '../../../../../common/util/group_color_utils'; + +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 } as EuiBadgeProps; + let jobCount; + + if (icon === true && removeId) { + // @ts-ignore + props = { + ...props, + iconType: 'cross', + iconSide: 'right', + onClick: () => removeId(id), + onClickAriaLabel: 'Remove id', + }; + } + + if (numJobs !== undefined) { + jobCount = i18n.translate('xpack.ml.jobSelector.selectedGroupJobs', { + defaultMessage: `({jobsCount, plural, one {# job} other {# jobs}})`, + values: { jobsCount: numJobs }, + }); + } + + return ( + + {`${id}${jobCount ? jobCount : ''}`} + + ); +}; 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 0000000000000..66aa05d2aaa97 --- /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 64793d15f1e4a..c55e03776c09d 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.js deleted file mode 100644 index 67dce47323889..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js +++ /dev/null @@ -1,75 +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 { PropTypes } from 'prop-types'; -import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; -import { JobSelectorBadge } from '../job_selector_badge'; -import { i18n } from '@kbn/i18n'; - -export function NewSelectionIdBadges({ - limit, - maps, - newSelection, - onDeleteClick, - onLinkClick, - showAllBadges, -}) { - const badges = []; - - for (let i = 0; i < newSelection.length; i++) { - if (i >= limit && showAllBadges === false) { - break; - } - - badges.push( - - - - ); - } - - if (showAllBadges === false && newSelection.length > limit) { - badges.push( - - - {i18n.translate('xpack.ml.jobSelector.showFlyoutBadges', { - defaultMessage: `And {overFlow} more`, - values: { overFlow: newSelection.length - limit }, - })} - - - ); - } else if (showAllBadges === true && newSelection.length > limit) { - badges.push( - - - {i18n.translate('xpack.ml.jobSelector.hideFlyoutBadges', { - defaultMessage: 'Hide', - })} - - - ); - } - - 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, -}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx new file mode 100644 index 0000000000000..4c018e72f3e10 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx @@ -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 React, { FC, MouseEventHandler } from 'react'; +import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { JobSelectorBadge } from '../job_selector_badge'; +import { JobSelectionMaps } from '../job_selector'; + +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++) { + if (i >= limit && showAllBadges === false) { + break; + } + + badges.push( + + + + ); + } + + if (showAllBadges === false && newSelection.length > limit) { + badges.push( + + + {i18n.translate('xpack.ml.jobSelector.showFlyoutBadges', { + defaultMessage: `And {overFlow} more`, + values: { overFlow: newSelection.length - limit }, + })} + + + ); + } else if (showAllBadges === true && newSelection.length > limit) { + badges.push( + + + {i18n.translate('xpack.ml.jobSelector.hideFlyoutBadges', { + defaultMessage: 'Hide', + })} + + + ); + } + + 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 86ffc4a2614b9..06d89ab782167 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 9fb2f0c3bed94..cfcba081983c2 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 d61d56d07b644..86d16776b68e2 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 5fc1160093a49..03426869b0ccf 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 71d777db5b2ec..06fd82204c1e1 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 dd9479be931a7..82041af39ca15 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 ca3e52308a936..54f541ceb7c3d 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 99de38c1e0a84..5b95931d31ab6 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( - '