diff --git a/.buildkite/package-lock.json b/.buildkite/package-lock.json index d848470b3ff68b..e8509e8126adba 100644 --- a/.buildkite/package-lock.json +++ b/.buildkite/package-lock.json @@ -8,7 +8,7 @@ "name": "kibana-buildkite", "version": "1.0.0", "dependencies": { - "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#b9f6b423059cac7554a7402277f2ad3ecfe132a4" + "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#a0037514b7650296a23dbad99b165601d4eab1be" } }, "node_modules/@nodelib/fs.scandir": { @@ -184,11 +184,24 @@ "follow-redirects": "^1.14.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, "node_modules/before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", "integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==" }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", @@ -355,14 +368,15 @@ }, "node_modules/kibana-buildkite-library": { "version": "1.0.0", - "resolved": "git+https://git@github.com/elastic/kibana-buildkite-library.git#b9f6b423059cac7554a7402277f2ad3ecfe132a4", - "integrity": "sha512-HsSPeCrKwJKa+1urq/AzELmA1hsrwZjmOKzWzEYcQ63ZAnn8G3QWrGL0dNZQxIto3243EEs6Ne1pUVTMtEJr+Q==", + "resolved": "git+https://git@github.com/elastic/kibana-buildkite-library.git#a0037514b7650296a23dbad99b165601d4eab1be", + "integrity": "sha512-W9oH2c0q21IbO3sKJR2BkebhDlXVuWfqKO1r6T/E8/RRxCXJg/Wf073k8aDdpl1Enk8Pq47F+lG7/IVT+kAcFA==", "license": "MIT", "dependencies": { "@octokit/rest": "^18.10.0", "axios": "^0.21.4", "globby": "^11.1.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1" } }, "node_modules/merge2": { @@ -385,6 +399,17 @@ "node": ">=8.6" } }, + "node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -684,11 +709,24 @@ "follow-redirects": "^1.14.0" } }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, "before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", "integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==" }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, "braces": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", @@ -801,14 +839,15 @@ } }, "kibana-buildkite-library": { - "version": "git+https://git@github.com/elastic/kibana-buildkite-library.git#b9f6b423059cac7554a7402277f2ad3ecfe132a4", - "integrity": "sha512-HsSPeCrKwJKa+1urq/AzELmA1hsrwZjmOKzWzEYcQ63ZAnn8G3QWrGL0dNZQxIto3243EEs6Ne1pUVTMtEJr+Q==", - "from": "kibana-buildkite-library@git+https://git@github.com/elastic/kibana-buildkite-library#b9f6b423059cac7554a7402277f2ad3ecfe132a4", + "version": "git+https://git@github.com/elastic/kibana-buildkite-library.git#a0037514b7650296a23dbad99b165601d4eab1be", + "integrity": "sha512-W9oH2c0q21IbO3sKJR2BkebhDlXVuWfqKO1r6T/E8/RRxCXJg/Wf073k8aDdpl1Enk8Pq47F+lG7/IVT+kAcFA==", + "from": "kibana-buildkite-library@git+https://git@github.com/elastic/kibana-buildkite-library#a0037514b7650296a23dbad99b165601d4eab1be", "requires": { "@octokit/rest": "^18.10.0", "axios": "^0.21.4", "globby": "^11.1.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1" } }, "merge2": { @@ -825,6 +864,14 @@ "picomatch": "^2.3.1" } }, + "minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", diff --git a/.buildkite/package.json b/.buildkite/package.json index 4e46ba6637027b..7f15a2fdf75bc2 100644 --- a/.buildkite/package.json +++ b/.buildkite/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "dependencies": { - "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#b9f6b423059cac7554a7402277f2ad3ecfe132a4" + "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#a0037514b7650296a23dbad99b165601d4eab1be" } } diff --git a/.buildkite/scripts/steps/package_testing/test.sh b/.buildkite/scripts/steps/package_testing/test.sh index e5ed00f760864a..390adc2dbaceec 100755 --- a/.buildkite/scripts/steps/package_testing/test.sh +++ b/.buildkite/scripts/steps/package_testing/test.sh @@ -41,9 +41,9 @@ trap "echoKibanaLogs" EXIT vagrant provision "$TEST_PACKAGE" -export TEST_BROWSER_HEADLESS=1 -export TEST_KIBANA_URL="http://elastic:changeme@$KIBANA_IP_ADDRESS:5601" -export TEST_ES_URL=http://elastic:changeme@192.168.56.1:9200 +# export TEST_BROWSER_HEADLESS=1 +# export TEST_KIBANA_URL="http://elastic:changeme@$KIBANA_IP_ADDRESS:5601" +# export TEST_ES_URL=http://elastic:changeme@192.168.56.1:9200 -cd x-pack -node scripts/functional_test_runner.js --include-tag=smoke +# cd x-pack +# node scripts/functional_test_runner.js --include-tag=smoke diff --git a/docs/maps/asset-tracking-tutorial.asciidoc b/docs/maps/asset-tracking-tutorial.asciidoc index ff62f5c019b74f..c53adf90ec3a2a 100644 --- a/docs/maps/asset-tracking-tutorial.asciidoc +++ b/docs/maps/asset-tracking-tutorial.asciidoc @@ -112,7 +112,7 @@ filter { "type" => "%{[resultSet][vehicle][type]}" "vehicle_id" => "%{[resultSet][vehicle][vehicleID]}" } - remove_field => [ "resultSet", "@version", "@timestamp" ] + remove_field => [ "resultSet", "@version", "@timestamp", "[event][original]" ] } mutate { diff --git a/package.json b/package.json index fad40777160fc4..577feb8f59ba4b 100644 --- a/package.json +++ b/package.json @@ -240,7 +240,7 @@ "constate": "^1.3.2", "content-disposition": "0.5.3", "copy-to-clipboard": "^3.0.8", - "core-js": "^3.21.1", + "core-js": "^3.22.4", "cronstrue": "^1.51.0", "cytoscape": "^3.10.0", "cytoscape-dagre": "^2.2.2", diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 79b41112768a64..818825096ffc19 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -603,7 +603,6 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { installElasticAgent: `${FLEET_DOCS}install-fleet-managed-elastic-agent.html`, installElasticAgentStandalone: `${FLEET_DOCS}install-standalone-elastic-agent.html`, upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`, - upgradeElasticAgent712lower: `${FLEET_DOCS}upgrade-elastic-agent.html#upgrade-7.12-lower`, learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`, apiKeysLearnMore: `${KIBANA_DOCS}api-keys.html`, onPremRegistry: `${FLEET_DOCS}air-gapped.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 645aad3af2bd24..2e14fccaccd297 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -359,7 +359,6 @@ export interface DocLinks { installElasticAgent: string; installElasticAgentStandalone: string; upgradeElasticAgent: string; - upgradeElasticAgent712lower: string; learnMoreBlog: string; apiKeysLearnMore: string; onPremRegistry: string; diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts index ac8cb5c8b86a34..705516a9f1e969 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts @@ -111,6 +111,7 @@ describe('interpreter/functions#gauge', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts index 48c7261e43016e..70ecd25839d194 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts @@ -194,6 +194,9 @@ export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({ } if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( data, [ diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts index 13beee6b0f7015..d34442ca3f518c 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts @@ -69,6 +69,7 @@ describe('interpreter/functions#heatmap', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts index c440176962faf1..954c5acee71522 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts @@ -161,6 +161,9 @@ export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({ validateAccessor(args.splitColumnAccessor, data.columns); if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const argsTable: Dimension[] = []; if (args.valueAccessor) { prepareHeatmapLogTable( diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts index 65f738e8e227d9..6524c15c44af1f 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts @@ -68,6 +68,7 @@ describe('interpreter/functions#metric', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index 2310ffb8c5926a..add31e7b120148 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts @@ -146,6 +146,9 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ validateAccessor(args.bucket, input.columns); if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const argsTable: Dimension[] = [ [ args.metric, diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts index 695b7ad4754fab..8c370480a7be99 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts @@ -54,4 +54,6 @@ export interface MetricOptions { color?: string; bgColor?: string; lightText: boolean; + colIndex: number; + rowIndex: number; } diff --git a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap b/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap index 684f42d527c19b..b18c521bea653e 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap @@ -19,6 +19,7 @@ Array [ metric={ Object { "bgColor": undefined, + "colIndex": 0, "color": undefined, "label": "1st percentile of bytes", "lightText": false, @@ -26,6 +27,7 @@ Array [ "value": 182, } } + onFilter={[Function]} style={ Object { "bgColor": false, @@ -53,6 +55,7 @@ Array [ metric={ Object { "bgColor": undefined, + "colIndex": 1, "color": undefined, "label": "99th percentile of bytes", "lightText": false, @@ -60,6 +63,7 @@ Array [ "value": 445842.4634666484, } } + onFilter={[Function]} style={ Object { "bgColor": false, @@ -91,6 +95,7 @@ exports[`MetricVisComponent should render correct structure for single metric 1` metric={ Object { "bgColor": undefined, + "colIndex": 0, "color": undefined, "label": "Count", "lightText": false, @@ -98,6 +103,7 @@ exports[`MetricVisComponent should render correct structure for single metric 1` "value": 4301021, } } + onFilter={[Function]} style={ Object { "bgColor": false, diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx index 50853ea6c65691..6fe19c0e725154 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx @@ -63,6 +63,7 @@ class MetricVisComponent extends Component { return dimensions.metrics.reduce( (acc: MetricOptions[], metric: string | ExpressionValueVisDimension) => { const column = getColumnByAccessor(metric, table?.columns); + const colIndex = table?.columns.indexOf(column!); const formatter = getFormatService().deserialize( getFormatByAccessor(metric, table.columns) ); @@ -89,6 +90,7 @@ class MetricVisComponent extends Component { bgColor: shouldBrush && (style.bgColor ?? false) ? color : undefined, lightText: shouldBrush && (style.bgColor ?? false) && needsLightText(color), rowIndex, + colIndex, }; }); @@ -98,20 +100,21 @@ class MetricVisComponent extends Component { ); } - private filterBucket = (row: number) => { + private filterColumn = (row: number, metricColIndex: number) => { const { dimensions } = this.props.visParams; - if (!dimensions.bucket) { - return; - } const table = this.props.visData; + let column = dimensions.bucket ? getAccessor(dimensions.bucket) : metricColIndex; + if (typeof column === 'object' && 'id' in column) { + column = table.columns.indexOf(column); + } this.props.fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data: [ { table, - column: getAccessor(dimensions.bucket), + column, row, }, ], @@ -144,9 +147,7 @@ class MetricVisComponent extends Component { key={index} metric={metric} style={this.props.visParams.metric.style} - onFilter={ - this.props.visParams.dimensions.bucket ? () => this.filterBucket(index) : undefined - } + onFilter={() => this.filterColumn(metric.rowIndex, metric.colIndex)} autoScale={this.props.visParams.metric.autoScale} colorFullBackground={this.props.visParams.metric.colorFullBackground} labelConfig={this.props.visParams.metric.labels} diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx index f86f70341891c0..fee24d8aa5e7f4 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx @@ -13,7 +13,13 @@ import { MetricVisValue } from './metric_value'; import { MetricOptions, MetricStyle, VisParams } from '../../common/types'; import { LabelPosition } from '../../common/constants'; -const baseMetric: MetricOptions = { label: 'Foo', value: 'foo', lightText: false }; +const baseMetric: MetricOptions = { + label: 'Foo', + value: 'foo', + lightText: false, + rowIndex: 0, + colIndex: 0, +}; const font: MetricStyle = { spec: { fontSize: '12px' }, diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx index e948b95af52fec..40de364cfa5dc6 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx @@ -8,6 +8,7 @@ import React, { CSSProperties } from 'react'; import classNames from 'classnames'; +import { i18n } from '@kbn/i18n'; import type { MetricOptions, MetricStyle, MetricVisParam } from '../../common/types'; interface MetricVisValueProps { @@ -72,7 +73,13 @@ export const MetricVisValue = ({ if (onFilter) { return ( - ); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts index 0ce174b38677fa..54b478e7deed95 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts @@ -135,6 +135,7 @@ describe('interpreter/functions#mosaicVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts index 2f08ecb28c9319..ae3f17ff8df3ac 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts @@ -144,6 +144,9 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts index 58ba8e837d3393..2ac50372e178db 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts @@ -129,6 +129,7 @@ describe('interpreter/functions#pieVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts index 707334466ea994..5b69fbc6194fd8 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts @@ -164,6 +164,9 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts index 5d2cd5b8a0c38b..e10dbf09dd179d 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts @@ -135,6 +135,7 @@ describe('interpreter/functions#treemapVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts index ab6f0c962e205b..427179ca5a25a4 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts @@ -144,6 +144,9 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts index 01cbe844728b32..af36e4ea04a109 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts @@ -106,6 +106,7 @@ describe('interpreter/functions#waffleVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts index 0311f5466142fa..0867e6cb9bd764 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts @@ -139,6 +139,9 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts index c9ddd7c30557b9..ccc365096495be 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts @@ -93,6 +93,7 @@ describe('interpreter/functions#tagcloud', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx index 49f376a8a4aa36..96857c2ec7426b 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx @@ -165,7 +165,7 @@ export const TagCloudChart = ({ } fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data: [ { diff --git a/src/plugins/chart_expressions/expression_xy/common/constants.ts b/src/plugins/chart_expressions/expression_xy/common/constants.ts index 931ece6ef8a786..68ac2963c96469 100644 --- a/src/plugins/chart_expressions/expression_xy/common/constants.ts +++ b/src/plugins/chart_expressions/expression_xy/common/constants.ts @@ -10,7 +10,6 @@ export const XY_VIS = 'xyVis'; export const LAYERED_XY_VIS = 'layeredXyVis'; export const Y_CONFIG = 'yConfig'; export const EXTENDED_Y_CONFIG = 'extendedYConfig'; -export const MULTITABLE = 'lens_multitable'; export const DATA_LAYER = 'dataLayer'; export const EXTENDED_DATA_LAYER = 'extendedDataLayer'; export const LEGEND_CONFIG = 'legendConfig'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 6ce3116d377e1c..5e2f7432ddf93d 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -62,6 +62,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { ]; if (handlers.inspectorAdapters.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const layerDimensions = layers.reduce((dimensions, layer) => { if (layer.layerType === LayerTypes.ANNOTATIONS) { return dimensions; diff --git a/src/plugins/chart_expressions/expression_xy/common/index.ts b/src/plugins/chart_expressions/expression_xy/common/index.ts index 4bee4a3e7f2b60..7211a7a7db1b76 100755 --- a/src/plugins/chart_expressions/expression_xy/common/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/index.ts @@ -29,7 +29,6 @@ export type { LegendConfig, IconPosition, DataLayerArgs, - LensMultiTable, ValueLabelMode, AxisExtentMode, DataLayerConfig, diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index 86eb173d4d4edc..3cd84fa14682c9 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -18,7 +18,6 @@ import { FittingFunctions, IconPositions, LayerTypes, - MULTITABLE, LineStyles, SeriesTypes, ValueLabelModes, @@ -296,15 +295,6 @@ export type XYExtendedLayerConfigResult = | ExtendedReferenceLineLayerConfigResult | ExtendedAnnotationLayerConfigResult; -export interface LensMultiTable { - type: typeof MULTITABLE; - tables: Record; - dateRange?: { - fromDate: Date; - toDate: Date; - }; -} - export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { type: typeof REFERENCE_LINE_LAYER; layerType: typeof LayerTypes.REFERENCELINE; diff --git a/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx b/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx index 194bfc2bf5c9d1..e84d8c001fb824 100644 --- a/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx @@ -8,7 +8,6 @@ import { Datatable } from '@kbn/expressions-plugin/common'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -import { LensMultiTable } from '../../common'; import { LayerTypes } from '../../common/constants'; import { DataLayerConfig, XYProps } from '../../common/types'; import { mockPaletteOutput, sampleArgs } from '../../common/__mocks__'; @@ -21,151 +20,142 @@ export const chartsActiveCursorService = chartStartContract.activeCursor; export const paletteService = chartPluginMock.createPaletteRegistry(); -export const dateHistogramData: LensMultiTable = { - type: 'lens_multitable', - tables: { - timeLayer: { - type: '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', +export const dateHistogramData: Datatable = { + type: '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', + field: 'order_date', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'date_histogram', + appliedTimeRange: { + from: '2020-04-01T16:14:16.246Z', + to: '2020-04-01T17:15:41.263Z', + }, + params: { field: 'order_date', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'date_histogram', - appliedTimeRange: { - from: '2020-04-01T16:14:16.246Z', - to: '2020-04-01T17:15:41.263Z', - }, - params: { - 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: {}, - }, - }, - params: { id: 'date', params: { pattern: 'HH:mm' } }, + 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: {}, }, }, - { - id: 'splitAccessorId', - name: 'Top values of category.keyword', - meta: { - type: 'string', + params: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'string', + field: 'category.keyword', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'terms', + params: { field: 'category.keyword', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'terms', - params: { - field: 'category.keyword', - orderBy: 'yAccessorId', - order: 'desc', - size: 3, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, - }, - params: { - id: 'terms', - params: { - id: 'string', - otherBucketLabel: 'Other', - missingBucketLabel: 'Missing', - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/jiy/app/kibana', - basePath: '/jiy', - }, - }, - }, + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', }, }, - { - id: 'yAccessorId', - name: 'Count of records', - meta: { - type: 'number', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - params: {}, + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', }, - params: { id: 'number' }, }, }, - ], + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'number', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + params: {}, + }, + params: { id: 'number' }, + }, }, - }, - dateRange: { - fromDate: new Date('2020-04-01T16:14:16.246Z'), - toDate: new Date('2020-04-01T17:15:41.263Z'), - }, + ], }; export const dateHistogramLayer: DataLayerConfig = { @@ -181,7 +171,7 @@ export const dateHistogramLayer: DataLayerConfig = { seriesType: 'bar_stacked', accessors: ['yAccessorId'], palette: mockPaletteOutput, - table: dateHistogramData.tables.timeLayer, + table: dateHistogramData, }; export function sampleArgsWithReferenceLine(value: number = 150) { diff --git a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx index 7c60a6a3a5769e..78ac1ed8d10cf6 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx @@ -7,151 +7,150 @@ */ import React from 'react'; +import { Datatable } from '@kbn/expressions-plugin/common'; import { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; import { EuiPopover } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { ComponentType, ReactWrapper } from 'enzyme'; -import type { DataLayerConfig, LensMultiTable } from '../../common'; +import type { DataLayerConfig } from '../../common'; import { LayerTypes } from '../../common/constants'; import { getLegendAction } from './legend_action'; import { LegendActionPopover } from './legend_action_popover'; import { mockPaletteOutput } from '../../common/__mocks__'; -const tables = { - first: { - type: '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', - field: 'order_date', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'date_histogram', - params: { - 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: {}, - }, +const table: Datatable = { + type: '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', + field: 'order_date', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'date_histogram', + params: { + 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: {}, }, - params: { id: 'date', params: { pattern: 'HH:mm' } }, }, + params: { id: 'date', params: { pattern: 'HH:mm' } }, }, - { - id: 'splitAccessorId', - name: 'Top values of category.keyword', - meta: { - type: 'string', - field: 'category.keyword', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'terms', - params: { - field: 'category.keyword', - orderBy: 'yAccessorId', - order: 'desc', - size: 3, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'string', + field: 'category.keyword', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'terms', + params: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', }, + }, + params: { + id: 'terms', params: { - id: 'terms', - params: { - id: 'string', - otherBucketLabel: 'Other', - missingBucketLabel: 'Missing', - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/jiy/app/kibana', - basePath: '/jiy', - }, + 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: 'number', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - params: {}, - }, - params: { id: 'number' }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'number', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + params: {}, }, + params: { id: 'number' }, }, - ], - }, -} as LensMultiTable['tables']; + }, + ], +}; const sampleLayer: DataLayerConfig = { layerId: 'first', @@ -166,7 +165,7 @@ const sampleLayer: DataLayerConfig = { yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, - table: tables.first, + table, }; describe('getLegendAction', function () { @@ -228,7 +227,7 @@ describe('getLegendAction', function () { { column: 1, row: 1, - table: tables.first, + table, value: "Women's Accessories", }, ], diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index 89ed1d4be8a25f..1d9d27c8ec9342 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -796,7 +796,7 @@ describe('XYChart component', () => { expect(onSelectRange).toHaveBeenCalledWith({ column: 0, - table: dateHistogramData.tables.timeLayer, + table: dateHistogramData, range: [1585757732783, 1585758880838], }); }); @@ -992,7 +992,7 @@ describe('XYChart component', () => { { column: 0, row: 0, - table: dateHistogramData.tables.timeLayer, + table: dateHistogramData, value: 1585758120000, }, ], diff --git a/src/plugins/charts/public/static/utils/transform_click_event.ts b/src/plugins/charts/public/static/utils/transform_click_event.ts index 3d0cc2b47b2159..ec35fa85d59a17 100644 --- a/src/plugins/charts/public/static/utils/transform_click_event.ts +++ b/src/plugins/charts/public/static/utils/transform_click_event.ts @@ -19,7 +19,7 @@ import { RangeSelectContext, ValueClickContext } from '@kbn/embeddable-plugin/pu import { Datatable } from '@kbn/expressions-plugin/public'; export interface ClickTriggerEvent { - name: 'filterBucket'; + name: 'filter'; data: ValueClickContext['data']; } @@ -214,7 +214,7 @@ export const getFilterFromChartClickEventFn = }); return { - name: 'filterBucket', + name: 'filter', data: { negate, data, @@ -250,7 +250,7 @@ export const getFilterFromSeriesFn = })); return { - name: 'filterBucket', + name: 'filter', data: { negate, data, diff --git a/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts b/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts index fe35f3ea900089..beddf6b6231105 100644 --- a/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts +++ b/src/plugins/data/common/search/aggs/metrics/filtered_metric.ts @@ -7,6 +7,8 @@ */ import { i18n } from '@kbn/i18n'; +import { buildEsQuery, buildQueryFilter } from '@kbn/es-query'; +import { getEsQueryConfig } from '../../..'; import { MetricAggType } from './metric_agg_type'; import { makeNestedLabel } from './lib/make_nested_label'; import { siblingPipelineAggHelper } from './lib/sibling_pipeline_agg_helper'; @@ -27,7 +29,11 @@ const filteredMetricTitle = i18n.translate('data.search.aggs.metrics.filteredMet defaultMessage: 'Filtered metric', }); -export const getFilteredMetricAgg = () => { +export interface FiltersMetricAggDependencies { + getConfig: (key: string) => T; +} + +export const getFilteredMetricAgg = ({ getConfig }: FiltersMetricAggDependencies) => { const { subtype, params, getSerializedFormat } = siblingPipelineAggHelper; return new MetricAggType({ @@ -39,6 +45,19 @@ export const getFilteredMetricAgg = () => { params: [...params(['filter'])], hasNoDslParams: true, getSerializedFormat, + createFilter: (agg, inputState) => { + const esQueryConfigs = getEsQueryConfig({ get: getConfig }); + return buildQueryFilter( + buildEsQuery( + agg.getIndexPattern(), + [agg.params.customBucket.params.filter], + [], + esQueryConfigs + ), + agg.getIndexPattern().id!, + agg.params.customBucket.params.filter.query + ); + }, getValue(agg, bucket) { const customMetric = agg.getParam('customMetric'); const customBucket = agg.getParam('customBucket'); diff --git a/src/plugins/data/common/search/aggs/metrics/lib/create_filter.ts b/src/plugins/data/common/search/aggs/metrics/lib/create_filter.ts new file mode 100644 index 00000000000000..a859e189ccfe52 --- /dev/null +++ b/src/plugins/data/common/search/aggs/metrics/lib/create_filter.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { buildExistsFilter } from '@kbn/es-query'; +import { AggConfig } from '../../agg_config'; +import { IMetricAggConfig } from '../metric_agg_type'; + +export const createMetricFilter = ( + aggConfig: TMetricAggConfig, + key: string +) => { + const indexPattern = aggConfig.getIndexPattern(); + if (aggConfig.getField()) { + return buildExistsFilter(aggConfig.getField(), indexPattern); + } +}; diff --git a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts index c96ba217779a6f..59bbe377ba28a7 100644 --- a/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts +++ b/src/plugins/data/common/search/aggs/metrics/metric_agg_type.ts @@ -13,6 +13,7 @@ import { AggConfig } from '../agg_config'; import { METRIC_TYPES } from './metric_agg_types'; import { BaseParamType, FieldTypes } from '../param_types'; import { AggGroupNames } from '../agg_groups'; +import { createMetricFilter } from './lib/create_filter'; export interface IMetricAggConfig extends AggConfig { type: InstanceType; @@ -48,6 +49,9 @@ export class MetricAggType {}; constructor(config: MetricAggTypeConfig) { + if (!config.createFilter) { + config.createFilter = createMetricFilter; + } super(config); this.params.push( diff --git a/src/plugins/vis_types/table/public/components/table_vis_columns.tsx b/src/plugins/vis_types/table/public/components/table_vis_columns.tsx index 9aa30f95f18093..25bd6b0b9031cc 100644 --- a/src/plugins/vis_types/table/public/components/table_vis_columns.tsx +++ b/src/plugins/vis_types/table/public/components/table_vis_columns.tsx @@ -35,7 +35,7 @@ export const createGridColumns = ( ) => { const onFilterClick = (data: FilterCellData, negate: boolean) => { fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data: [ { diff --git a/src/plugins/vis_types/table/public/table_vis_fn.test.ts b/src/plugins/vis_types/table/public/table_vis_fn.test.ts index 98336d6cc67d4e..87da839578117c 100644 --- a/src/plugins/vis_types/table/public/table_vis_fn.test.ts +++ b/src/plugins/vis_types/table/public/table_vis_fn.test.ts @@ -79,6 +79,7 @@ describe('interpreter/functions#table', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx b/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx index e29d47844950e3..181ea661b69f8c 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx @@ -125,7 +125,7 @@ function TimeseriesVisualization({ const data = getClickFilterData(points, tables, model); const event = { - name: 'filterBucket', + name: 'filter', data: { data, negate: false, diff --git a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx index 7f948917764df3..5c4c4e3c2c1452 100644 --- a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx +++ b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx @@ -201,7 +201,7 @@ describe('VisLegend Component', () => { }); expect(fireEvent).toHaveBeenCalledWith({ - name: 'filterBucket', + name: 'filter', data: { data: ['valuesA'], negate: false }, }); expect(fireEvent).toHaveBeenCalledTimes(1); @@ -216,7 +216,7 @@ describe('VisLegend Component', () => { }); expect(fireEvent).toHaveBeenCalledWith({ - name: 'filterBucket', + name: 'filter', data: { data: ['valuesA'], negate: true }, }); expect(fireEvent).toHaveBeenCalledTimes(1); diff --git a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx index 577a76dd844542..fedeb03cdde281 100644 --- a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx +++ b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx @@ -87,7 +87,7 @@ export class VisLegend extends PureComponent { filter = ({ values: data }: LegendItem, negate: boolean) => { this.props.fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data, negate, diff --git a/src/plugins/vis_types/vislib/public/vislib/lib/handler.js b/src/plugins/vis_types/vislib/public/vislib/lib/handler.js index fe8388e025b94b..177febfb2812c5 100644 --- a/src/plugins/vis_types/vislib/public/vislib/lib/handler.js +++ b/src/plugins/vis_types/vislib/public/vislib/lib/handler.js @@ -95,7 +95,7 @@ export class Handler { }); case 'click': return self.vis.emit(eventType, { - name: 'filterBucket', + name: 'filter', data: eventPayload, }); } diff --git a/src/setup_node_env/ensure_node_preserve_symlinks.js b/src/setup_node_env/ensure_node_preserve_symlinks.js index 38995642036225..5ec286801bdc4b 100644 --- a/src/setup_node_env/ensure_node_preserve_symlinks.js +++ b/src/setup_node_env/ensure_node_preserve_symlinks.js @@ -89,10 +89,18 @@ } if (spawnResult.signal !== null) { - return 128 + spawnResult.signal; + console.log( + 'ensure_node_preserve_symlinks wrapper: process exitted with signal', + spawnResult.signal + ); + return 1; } if (spawnResult.error) { + console.log( + 'ensure_node_preserve_symlinks wrapper: process exitted with error', + spawnResult.error + ); return 1; } diff --git a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts index eb6c8fb0888f25..b6f691f419dabb 100644 --- a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts +++ b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts @@ -15,8 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const ebtUIHelper = getService('kibana_ebt_ui'); const { common } = getPageObjects(['common']); - // FLAKY: https://github.com/elastic/kibana/issues/131729 - describe.skip('Core Context Providers', () => { + describe('Core Context Providers', () => { let event: Event; before(async () => { await common.navigateToApp('home'); @@ -73,7 +72,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(event.context).not.to.have.property('page'); // In the Home app it's not available. }); - it('should have the properties provided by the "license info" context provider', () => { + it('should have the properties provided by the "license info" context provider', async () => { + await common.clickAndValidate('kibanaChrome', 'kibanaChrome'); + [event] = await ebtUIHelper.getLastEvents(1, ['click']); // Get a later event to ensure license has been obtained already. expect(event.context).to.have.property('license_id'); expect(event.context.license_id).to.be.a('string'); expect(event.context).to.have.property('license_status'); diff --git a/test/functional/apps/visualize/group2/_metric_chart.ts b/test/functional/apps/visualize/group2/_metric_chart.ts index b797ccb6303637..d28835ea556e3d 100644 --- a/test/functional/apps/visualize/group2/_metric_chart.ts +++ b/test/functional/apps/visualize/group2/_metric_chart.ts @@ -171,14 +171,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('with filters', function () { - it('should prevent filtering without buckets', async function () { + it('should allow filtering without buckets', async function () { let filterCount = 0; await retry.try(async function tryingForTime() { // click first metric bucket await PageObjects.visEditor.clickMetricByIndex(0); filterCount = await filterBar.getFilterCount(); }); - expect(filterCount).to.equal(0); + await filterBar.removeAllFilters(); + expect(filterCount).to.equal(1); }); it('should allow filtering with buckets', async function () { diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts new file mode 100644 index 00000000000000..bcbb72518e4e47 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { AlertingEventLogger } from './alerting_event_logger'; + +const createAlertingEventLoggerMock = () => { + const mock: jest.Mocked> = { + initialize: jest.fn(), + start: jest.fn(), + getEvent: jest.fn(), + getStartAndDuration: jest.fn(), + setRuleName: jest.fn(), + setExecutionSucceeded: jest.fn(), + setExecutionFailed: jest.fn(), + logTimeout: jest.fn(), + logAlert: jest.fn(), + logAction: jest.fn(), + done: jest.fn(), + }; + return mock; +}; + +export const alertingEventLoggerMock = { + create: createAlertingEventLoggerMock, +}; diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts new file mode 100644 index 00000000000000..c980d61bb08fe1 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts @@ -0,0 +1,1115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; +import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; +import { + AlertingEventLogger, + RuleContextOpts, + initializeExecuteRecord, + createExecuteStartRecord, + createExecuteTimeoutRecord, + createAlertRecord, + createActionExecuteRecord, + updateEvent, +} from './alerting_event_logger'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; +import { + ActionsCompletion, + RecoveredActionGroup, + RuleExecutionStatusErrorReasons, + RuleExecutionStatusWarningReasons, +} from '../../types'; +import { RuleRunMetrics } from '../rule_run_metrics_store'; +import { EVENT_LOG_ACTIONS } from '../../plugin'; + +const mockNow = '2020-01-01T02:00:00.000Z'; +const eventLogger = eventLoggerMock.create(); + +const ruleType: jest.Mocked = { + id: 'test', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + ruleTaskTimeout: '1m', +}; + +const context: RuleContextOpts = { + ruleId: '123', + ruleType, + consumer: 'test-consumer', + spaceId: 'test-space', + executionId: 'abcd-efgh-ijklmnop', + taskScheduledAt: new Date('2020-01-01T00:00:00.000Z'), +}; + +const contextWithScheduleDelay = { ...context, taskScheduleDelay: 7200000 }; +const contextWithName = { ...contextWithScheduleDelay, ruleName: 'my-super-cool-rule' }; + +const alert = { + action: EVENT_LOG_ACTIONS.activeInstance, + id: 'aaabbb', + message: `.test-rule-type:123: 'my rule' active alert: 'aaabbb' in actionGroup: 'aGroup'; actionSubGroup: 'bSubGroup'`, + group: 'aGroup', + subgroup: 'bSubgroup', + state: { + start: '2020-01-01T02:00:00.000Z', + end: '2020-01-01T03:00:00.000Z', + duration: '2343252346', + }, +}; + +const action = { + id: 'abc', + typeId: '.email', + alertId: '123', + alertGroup: 'aGroup', + alertSubgroup: 'bSubgroup', +}; + +describe('AlertingEventLogger', () => { + let alertingEventLogger: AlertingEventLogger; + + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date(mockNow)); + }); + + beforeEach(() => { + jest.resetAllMocks(); + alertingEventLogger = new AlertingEventLogger(eventLogger); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('initialize()', () => { + test('initialization should succeed if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.initialize(context)).not.toThrow(); + }); + + test('initialization should fail if alertingEventLogger has already been initialized', () => { + alertingEventLogger.initialize(context); + expect(() => alertingEventLogger.initialize(context)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger already initialized"` + ); + }); + }); + + describe('start()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.start()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.start()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.start()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should call eventLogger "startTiming" and "logEvent"', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + + expect(eventLogger.startTiming).toHaveBeenCalledWith( + initializeExecuteRecord(contextWithScheduleDelay), + new Date(mockNow) + ); + expect(eventLogger.logEvent).toHaveBeenCalledWith( + createExecuteStartRecord(contextWithScheduleDelay, new Date(mockNow)) + ); + }); + + test('should initialize the "execute" event', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + }, + }); + }); + }); + + describe('setRuleName()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.setRuleName('')).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => alertingEventLogger.setRuleName('')).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should update event with rule name correctly', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.setRuleName('my-super-cool-rule'); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + rule: { + ...event.rule, + name: 'my-super-cool-rule', + }, + }); + }); + }); + + describe('setExecutionSucceeded()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => + alertingEventLogger.setExecutionSucceeded('') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => + alertingEventLogger.setExecutionSucceeded('') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should update execute event correctly', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.setRuleName('my-super-cool-rule'); + alertingEventLogger.setExecutionSucceeded('success!'); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + outcome: 'success', + }, + rule: { + ...event.rule, + name: 'my-super-cool-rule', + }, + message: 'success!', + }); + }); + }); + + describe('setExecutionFailed()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => + alertingEventLogger.setExecutionFailed('', '') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => + alertingEventLogger.setExecutionFailed('', '') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should update execute event correctly', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.setExecutionFailed('rule failed!', 'something went wrong!'); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + outcome: 'failure', + }, + error: { + message: 'something went wrong!', + }, + message: 'rule failed!', + }); + }); + }); + + describe('logTimeout()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log timeout event', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.logTimeout(); + + const event = createExecuteTimeoutRecord(contextWithName); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('logAlert()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log timeout event', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.logAlert(alert); + + const event = createAlertRecord(contextWithName, alert); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('logAction()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log timeout event', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.logAction(action); + + const event = createActionExecuteRecord(contextWithName, action); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('done()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log event if no status or metrics are provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({}); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + + test('should set fields from execution status if provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), status: 'active' }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + kibana: { + ...event?.kibana, + alerting: { + status: 'active', + }, + }, + }; + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is error', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'error', + error: { + reason: RuleExecutionStatusErrorReasons.Execute, + message: 'something went wrong', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + outcome: 'failure', + reason: RuleExecutionStatusErrorReasons.Execute, + }, + error: { + message: 'something went wrong', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'error', + }, + }, + message: 'test:123: execution failed', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is error and uses "unknown" if no reason is provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'error', + error: { + reason: undefined as unknown as RuleExecutionStatusErrorReasons, + message: 'something went wrong', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + outcome: 'failure', + reason: 'unknown', + }, + error: { + message: 'something went wrong', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'error', + }, + }, + message: 'test:123: execution failed', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is error and does not overwrite existing error message', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'error', + error: { + reason: undefined as unknown as RuleExecutionStatusErrorReasons, + message: 'something went wrong', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + alertingEventLogger.setExecutionFailed( + 'i am an existing error message', + 'i am an existing error message!' + ); + const loggedEvent = { + ...event, + event: { + ...event?.event, + outcome: 'failure', + reason: 'unknown', + }, + error: { + message: 'i am an existing error message!', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'error', + }, + }, + message: 'i am an existing error message', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is warning', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'warning', + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'something funky happened', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'warning', + }, + }, + message: 'something funky happened', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is warning and uses "unknown" if no reason is provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'warning', + warning: { + reason: undefined as unknown as RuleExecutionStatusWarningReasons, + message: 'something funky happened', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + reason: 'unknown', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'warning', + }, + }, + message: 'something funky happened', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is warning and uses existing message if no message is provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'warning', + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: undefined as unknown as string, + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + alertingEventLogger.setExecutionSucceeded('success!'); + const loggedEvent = { + ...event, + event: { + ...event?.event, + reason: 'maxExecutableActions', + outcome: 'success', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'warning', + }, + }, + message: 'success!', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution metrics if provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + metrics: { + numberOfTriggeredActions: 1, + numberOfGeneratedActions: 2, + numberOfActiveAlerts: 3, + numberOfNewAlerts: 4, + numberOfRecoveredAlerts: 5, + numSearches: 6, + esSearchDurationMs: 3300, + totalSearchDurationMs: 10333, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + execution: { + ...event.kibana?.alert?.rule?.execution, + metrics: { + number_of_triggered_actions: 1, + number_of_generated_actions: 2, + number_of_active_alerts: 3, + number_of_new_alerts: 4, + number_of_recovered_alerts: 5, + total_number_of_alerts: 8, + number_of_searches: 6, + es_search_duration_ms: 3300, + total_search_duration_ms: 10333, + }, + }, + }, + }, + }, + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields to 0 execution metrics are provided but undefined', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + metrics: {} as unknown as RuleRunMetrics, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + execution: { + ...event.kibana?.alert?.rule?.execution, + metrics: { + number_of_triggered_actions: 0, + number_of_generated_actions: 0, + number_of_active_alerts: 0, + number_of_new_alerts: 0, + number_of_recovered_alerts: 0, + total_number_of_alerts: 0, + number_of_searches: 0, + es_search_duration_ms: 0, + total_search_duration_ms: 0, + }, + }, + }, + }, + }, + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + }); +}); + +describe('createExecuteStartRecord', () => { + test('should create execute-start record', () => { + const executeRecord = initializeExecuteRecord(contextWithScheduleDelay); + const record = createExecuteStartRecord(contextWithScheduleDelay); + + expect(record).toEqual({ + ...executeRecord, + event: { + ...executeRecord.event, + action: 'execute-start', + }, + message: `rule execution start: "123"`, + }); + }); + + test('should create execute-start record with given start time', () => { + const executeRecord = initializeExecuteRecord(contextWithScheduleDelay); + const record = createExecuteStartRecord( + contextWithScheduleDelay, + new Date('2022-01-01T02:00:00.000Z') + ); + + expect(record).toEqual({ + ...executeRecord, + event: { + ...executeRecord.event, + action: 'execute-start', + start: '2022-01-01T02:00:00.000Z', + }, + message: `rule execution start: "123"`, + }); + }); +}); + +describe('initializeExecuteRecord', () => { + test('should populate initial set of fields in event log record', () => { + const record = initializeExecuteRecord(contextWithScheduleDelay); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.kibana?.task).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([contextWithScheduleDelay.ruleType.producer]); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithScheduleDelay.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithScheduleDelay.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual( + contextWithScheduleDelay.executionId + ); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithScheduleDelay.ruleId, + type: 'alert', + type_id: contextWithScheduleDelay.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithScheduleDelay.spaceId]); + expect(record.kibana?.task?.scheduled).toEqual( + contextWithScheduleDelay.taskScheduledAt.toISOString() + ); + expect(record.kibana?.task?.schedule_delay).toEqual( + contextWithScheduleDelay.taskScheduleDelay * 1000000 + ); + expect(record?.rule?.id).toEqual(contextWithScheduleDelay.ruleId); + expect(record?.rule?.license).toEqual(contextWithScheduleDelay.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithScheduleDelay.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithScheduleDelay.ruleType.producer); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alerting).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.rule?.name).toBeUndefined(); + expect(record?.message).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('createExecuteTimeoutRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createExecuteTimeoutRecord(contextWithName); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute-timeout'); + expect(record.event?.kind).toEqual('alert'); + expect(record.message).toEqual( + `rule: test:123: 'my-super-cool-rule' execution cancelled due to timeout - exceeded rule type timeout of 1m` + ); + expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithName.ruleId, + type: 'alert', + type_id: contextWithName.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); + expect(record?.rule?.id).toEqual(contextWithName.ruleId); + expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); + expect(record?.rule?.name).toEqual(contextWithName.ruleName); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alerting).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('createAlertRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createAlertRecord(contextWithName, alert); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('active-instance'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); + expect(record.event?.start).toEqual(alert.state.start); + expect(record.event?.end).toEqual(alert.state.end); + expect(record.event?.duration).toEqual(alert.state.duration); + expect(record.message).toEqual( + `.test-rule-type:123: 'my rule' active alert: 'aaabbb' in actionGroup: 'aGroup'; actionSubGroup: 'bSubGroup'` + ); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); + expect(record.kibana?.alerting?.instance_id).toEqual(alert.id); + expect(record.kibana?.alerting?.action_group_id).toEqual(alert.group); + expect(record.kibana?.alerting?.action_subgroup).toEqual(alert.subgroup); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithName.ruleId, + type: 'alert', + type_id: contextWithName.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); + expect(record?.rule?.id).toEqual(contextWithName.ruleId); + expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); + expect(record?.rule?.name).toEqual(contextWithName.ruleName); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('createActionExecuteRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createActionExecuteRecord(contextWithName, action); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute-action'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); + expect(record.message).toEqual( + `alert: test:123: 'my-super-cool-rule' instanceId: '123' scheduled actionGroup(subgroup): 'aGroup(bSubgroup)' action: .email:abc` + ); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); + expect(record.kibana?.alerting?.instance_id).toEqual(action.alertId); + expect(record.kibana?.alerting?.action_group_id).toEqual(action.alertGroup); + expect(record.kibana?.alerting?.action_subgroup).toEqual(action.alertSubgroup); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithName.ruleId, + type: 'alert', + type_id: contextWithName.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + { + id: action.id, + type: 'action', + type_id: action.typeId, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); + expect(record?.rule?.id).toEqual(contextWithName.ruleId); + expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); + expect(record?.rule?.name).toEqual(contextWithName.ruleName); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('updateEvent', () => { + let event: IEvent; + let expectedEvent: IEvent; + beforeEach(() => { + event = initializeExecuteRecord(contextWithScheduleDelay); + expectedEvent = initializeExecuteRecord(contextWithScheduleDelay); + }); + + test('throws error if event is null', () => { + expect(() => updateEvent(null as unknown as IEvent, {})).toThrowErrorMatchingInlineSnapshot( + `"Cannot update event because it is not initialized."` + ); + }); + + test('throws error if event is undefined', () => { + expect(() => + updateEvent(undefined as unknown as IEvent, {}) + ).toThrowErrorMatchingInlineSnapshot(`"Cannot update event because it is not initialized."`); + }); + + test('updates event message if provided', () => { + updateEvent(event, { message: 'tell me something good' }); + expect(event).toEqual({ + ...expectedEvent, + message: 'tell me something good', + }); + }); + + test('updates event outcome if provided', () => { + updateEvent(event, { outcome: 'yay' }); + expect(event).toEqual({ + ...expectedEvent, + event: { + ...expectedEvent?.event, + outcome: 'yay', + }, + }); + }); + + test('updates event error if provided', () => { + updateEvent(event, { error: 'oh no' }); + expect(event).toEqual({ + ...expectedEvent, + error: { + message: 'oh no', + }, + }); + }); + + test('updates event rule name if provided', () => { + updateEvent(event, { ruleName: 'test rule' }); + expect(event).toEqual({ + ...expectedEvent, + rule: { + ...expectedEvent?.rule, + name: 'test rule', + }, + }); + }); + + test('updates event status if provided', () => { + updateEvent(event, { status: 'ok' }); + expect(event).toEqual({ + ...expectedEvent, + kibana: { + ...expectedEvent?.kibana, + alerting: { + status: 'ok', + }, + }, + }); + }); + + test('updates event reason if provided', () => { + updateEvent(event, { reason: 'my-reason' }); + expect(event).toEqual({ + ...expectedEvent, + event: { + ...expectedEvent?.event, + reason: 'my-reason', + }, + }); + }); + + test('updates all fields if provided', () => { + updateEvent(event, { + message: 'tell me something good', + outcome: 'yay', + error: 'oh no', + ruleName: 'test rule', + status: 'ok', + reason: 'my-reason', + }); + expect(event).toEqual({ + ...expectedEvent, + message: 'tell me something good', + kibana: { + ...expectedEvent?.kibana, + alerting: { + status: 'ok', + }, + }, + event: { + ...expectedEvent?.event, + outcome: 'yay', + reason: 'my-reason', + }, + error: { + message: 'oh no', + }, + rule: { + ...expectedEvent?.rule, + name: 'test rule', + }, + }); + }); +}); + +function mockEventLoggerStartTiming() { + eventLogger.startTiming.mockImplementationOnce((event: IEvent, startTime?: Date) => { + if (event == null) return; + event.event = event.event || {}; + + const start = startTime ?? new Date(); + event.event.start = start.toISOString(); + }); +} diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts new file mode 100644 index 00000000000000..74a8a26f531f22 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts @@ -0,0 +1,389 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; +import { EVENT_LOG_ACTIONS } from '../../plugin'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; +import { AlertInstanceState, RuleExecutionStatus } from '../../types'; +import { createAlertEventLogRecordObject } from '../create_alert_event_log_record_object'; +import { RuleRunMetrics } from '../rule_run_metrics_store'; + +// 1,000,000 nanoseconds in 1 millisecond +const Millis2Nanos = 1000 * 1000; + +export interface RuleContextOpts { + ruleId: string; + ruleType: UntypedNormalizedRuleType; + consumer: string; + namespace?: string; + spaceId: string; + executionId: string; + taskScheduledAt: Date; + ruleName?: string; +} + +type RuleContext = RuleContextOpts & { + taskScheduleDelay: number; +}; + +interface DoneOpts { + status?: RuleExecutionStatus; + metrics?: RuleRunMetrics | null; +} + +interface AlertOpts { + action: string; + id: string; + message: string; + group?: string; + subgroup?: string; + state?: AlertInstanceState; +} + +interface ActionOpts { + id: string; + typeId: string; + alertId: string; + alertGroup?: string; + alertSubgroup?: string; +} + +export class AlertingEventLogger { + private eventLogger: IEventLogger; + private isInitialized = false; + private startTime?: Date; + private ruleContext?: RuleContextOpts; + + // this is the "execute" event that will be updated over the lifecycle of this class + private event: IEvent; + + constructor(eventLogger: IEventLogger) { + this.eventLogger = eventLogger; + } + + // For testing purposes + public getEvent(): IEvent { + return this.event; + } + + public initialize(context: RuleContextOpts) { + if (this.isInitialized) { + throw new Error('AlertingEventLogger already initialized'); + } + this.isInitialized = true; + this.ruleContext = context; + } + + public start() { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.startTime = new Date(); + + const context = { + ...this.ruleContext, + taskScheduleDelay: this.startTime.getTime() - this.ruleContext.taskScheduledAt.getTime(), + }; + + // Initialize the "execute" event + this.event = initializeExecuteRecord(context); + this.eventLogger.startTiming(this.event, this.startTime); + + // Create and log "execute-start" event + const executeStartEvent = createExecuteStartRecord(context, this.startTime); + this.eventLogger.logEvent(executeStartEvent); + } + + public getStartAndDuration(): { start?: Date; duration?: string | number } { + return { start: this.startTime, duration: this.event?.event?.duration }; + } + + public setRuleName(ruleName: string) { + if (!this.isInitialized || !this.event || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.ruleContext.ruleName = ruleName; + updateEvent(this.event, { ruleName }); + } + + public setExecutionSucceeded(message: string) { + if (!this.isInitialized || !this.event) { + throw new Error('AlertingEventLogger not initialized'); + } + + updateEvent(this.event, { message, outcome: 'success' }); + } + + public setExecutionFailed(message: string, errorMessage: string) { + if (!this.isInitialized || !this.event) { + throw new Error('AlertingEventLogger not initialized'); + } + + updateEvent(this.event, { message, outcome: 'failure', error: errorMessage }); + } + + public logTimeout() { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.logEvent(createExecuteTimeoutRecord(this.ruleContext)); + } + + public logAlert(alert: AlertOpts) { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.logEvent(createAlertRecord(this.ruleContext, alert)); + } + + public logAction(action: ActionOpts) { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.logEvent(createActionExecuteRecord(this.ruleContext, action)); + } + + public done({ status, metrics }: DoneOpts) { + if (!this.isInitialized || !this.event || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.stopTiming(this.event); + + if (status) { + updateEvent(this.event, { status: status.status }); + + if (status.error) { + updateEvent(this.event, { + outcome: 'failure', + reason: status.error?.reason || 'unknown', + error: this.event?.error?.message || status.error.message, + ...(this.event.message + ? {} + : { + message: `${this.ruleContext.ruleType.id}:${this.ruleContext.ruleId}: execution failed`, + }), + }); + } else { + if (status.warning) { + updateEvent(this.event, { + reason: status.warning?.reason || 'unknown', + message: status.warning?.message || this.event?.message, + }); + } + } + } + + if (metrics) { + updateEvent(this.event, { metrics }); + } + + this.eventLogger.logEvent(this.event); + } +} + +export function createExecuteStartRecord(context: RuleContext, startTime?: Date) { + const event = initializeExecuteRecord(context); + return { + ...event, + event: { + ...event.event, + action: EVENT_LOG_ACTIONS.executeStart, + ...(startTime ? { start: startTime.toISOString() } : {}), + }, + message: `rule execution start: "${context.ruleId}"`, + }; +} + +export function createAlertRecord(context: RuleContextOpts, alert: AlertOpts) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: alert.action, + state: alert.state, + instanceId: alert.id, + group: alert.group, + subgroup: alert.subgroup, + message: alert.message, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + ruleName: context.ruleName, + }); +} + +export function createActionExecuteRecord(context: RuleContextOpts, action: ActionOpts) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.executeAction, + instanceId: action.alertId, + group: action.alertGroup, + subgroup: action.alertSubgroup, + message: `alert: ${context.ruleType.id}:${context.ruleId}: '${context.ruleName}' instanceId: '${ + action.alertId + }' scheduled ${ + action.alertSubgroup + ? `actionGroup(subgroup): '${action.alertGroup}(${action.alertSubgroup})'` + : `actionGroup: '${action.alertGroup}'` + } action: ${action.typeId}:${action.id}`, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + { + type: 'action', + id: action.id, + typeId: action.typeId, + }, + ], + ruleName: context.ruleName, + }); +} + +export function createExecuteTimeoutRecord(context: RuleContextOpts) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.executeTimeout, + message: `rule: ${context.ruleType.id}:${context.ruleId}: '${ + context.ruleName ?? '' + }' execution cancelled due to timeout - exceeded rule type timeout of ${ + context.ruleType.ruleTaskTimeout + }`, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + ruleName: context.ruleName, + }); +} + +export function initializeExecuteRecord(context: RuleContext) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.execute, + task: { + scheduled: context.taskScheduledAt.toISOString(), + scheduleDelay: Millis2Nanos * context.taskScheduleDelay, + }, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + }); +} + +interface UpdateEventOpts { + message?: string; + outcome?: string; + error?: string; + ruleName?: string; + status?: string; + reason?: string; + metrics?: RuleRunMetrics; +} +export function updateEvent(event: IEvent, opts: UpdateEventOpts) { + const { message, outcome, error, ruleName, status, reason, metrics } = opts; + if (!event) { + throw new Error('Cannot update event because it is not initialized.'); + } + if (message) { + event.message = message; + } + + if (outcome) { + event.event = event.event || {}; + event.event.outcome = outcome; + } + + if (error) { + event.error = event.error || {}; + event.error.message = error; + } + + if (ruleName) { + event.rule = { + ...event.rule, + name: ruleName, + }; + } + + if (status) { + event.kibana = event.kibana || {}; + event.kibana.alerting = event.kibana.alerting || {}; + event.kibana.alerting.status = status; + } + + if (reason) { + event.event = event.event || {}; + event.event.reason = reason; + } + + if (metrics) { + event.kibana = event.kibana || {}; + event.kibana.alert = event.kibana.alert || {}; + event.kibana.alert.rule = event.kibana.alert.rule || {}; + event.kibana.alert.rule.execution = event.kibana.alert.rule.execution || {}; + event.kibana.alert.rule.execution.metrics = { + number_of_triggered_actions: metrics.numberOfTriggeredActions + ? metrics.numberOfTriggeredActions + : 0, + number_of_generated_actions: metrics.numberOfGeneratedActions + ? metrics.numberOfGeneratedActions + : 0, + number_of_active_alerts: metrics.numberOfActiveAlerts ? metrics.numberOfActiveAlerts : 0, + number_of_new_alerts: metrics.numberOfNewAlerts ? metrics.numberOfNewAlerts : 0, + number_of_recovered_alerts: metrics.numberOfRecoveredAlerts + ? metrics.numberOfRecoveredAlerts + : 0, + total_number_of_alerts: + (metrics.numberOfActiveAlerts ?? 0) + (metrics.numberOfRecoveredAlerts ?? 0), + number_of_searches: metrics.numSearches ? metrics.numSearches : 0, + es_search_duration_ms: metrics.esSearchDurationMs ? metrics.esSearchDurationMs : 0, + total_search_duration_ms: metrics.totalSearchDurationMs ? metrics.totalSearchDurationMs : 0, + }; + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index 81f7fa7da02d22..b4ae05fd341b03 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -13,7 +13,6 @@ import { actionsMock, renderActionParameterTemplatesDefault, } from '@kbn/actions-plugin/server/mocks'; -import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { KibanaRequest } from '@kbn/core/server'; import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import { InjectActionParamsOpts } from './inject_action_params'; @@ -26,11 +25,14 @@ import { RuleTypeState, } from '../types'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; jest.mock('./inject_action_params', () => ({ injectActionParams: jest.fn(), })); +const alertingEventLogger = alertingEventLoggerMock.create(); + const ruleType: NormalizedRuleType< RuleTypeParams, RuleTypeParams, @@ -60,7 +62,6 @@ const ruleType: NormalizedRuleType< const actionsClient = actionsClientMock.create(); const mockActionsPlugin = actionsMock.createStart(); -const mockEventLogger = eventLoggerMock.create(); const createExecutionHandlerParams: jest.Mocked< CreateExecutionHandlerOptions< RuleTypeParams, @@ -83,7 +84,7 @@ const createExecutionHandlerParams: jest.Mocked< kibanaBaseUrl: 'http://localhost:5601', ruleType, logger: loggingSystemMock.create().get(), - eventLogger: mockEventLogger, + alertingEventLogger, actions: [ { id: '1', @@ -178,63 +179,13 @@ describe('Create Execution Handler', () => { ] `); - expect(mockEventLogger.logEvent).toHaveBeenCalledTimes(1); - expect(mockEventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "event": Object { - "action": "execute-action", - "category": Array [ - "alerts", - ], - "kind": "alert", - }, - "kibana": Object { - "alert": Object { - "rule": Object { - "consumer": "rule-consumer", - "execution": Object { - "uuid": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", - }, - "rule_type_id": "test", - }, - }, - "alerting": Object { - "action_group_id": "default", - "instance_id": "2", - }, - "saved_objects": Array [ - Object { - "id": "1", - "namespace": "test1", - "rel": "primary", - "type": "alert", - "type_id": "test", - }, - Object { - "id": "1", - "namespace": "test1", - "type": "action", - "type_id": "test", - }, - ], - "space_ids": Array [ - "test1", - ], - }, - "message": "alert: test:1: 'name-of-alert' instanceId: '2' scheduled actionGroup: 'default' action: test:1", - "rule": Object { - "category": "test", - "id": "1", - "license": "basic", - "name": "name-of-alert", - "ruleset": "alerts", - }, - }, - ], - ] - `); + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { + id: '1', + typeId: 'test', + alertId: '2', + alertGroup: 'default', + }); expect(jest.requireMock('./inject_action_params').injectActionParams).toHaveBeenCalledWith({ ruleId: '1', diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index ce212a3cbff1b3..0383289ab91dfb 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -5,10 +5,8 @@ * 2.0. */ import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; -import { SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; import { isEphemeralTaskRejectedDueToCapacityError } from '@kbn/task-manager-plugin/server'; import { transformActionParams } from './transform_action_params'; -import { EVENT_LOG_ACTIONS } from '../plugin'; import { injectActionParams } from './inject_action_params'; import { ActionsCompletion, @@ -17,9 +15,6 @@ import { RuleTypeParams, RuleTypeState, } from '../types'; - -import { UntypedNormalizedRuleType } from '../rule_type_registry'; -import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; import { CreateExecutionHandlerOptions, ExecutionHandlerOptions } from './types'; export type ExecutionHandler = ( @@ -47,7 +42,7 @@ export function createExecutionHandler< apiKey, ruleType, kibanaBaseUrl, - eventLogger, + alertingEventLogger, request, ruleParams, supportsEphemeralTasks, @@ -117,8 +112,6 @@ export function createExecutionHandler< ruleRunMetricsStore.incrementNumberOfGeneratedActions(actions.length); - const ruleLabel = `${ruleType.id}:${ruleId}: '${ruleName}'`; - const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); let ephemeralActionsToSchedule = maxEphemeralActionsPerRule; @@ -189,8 +182,6 @@ export function createExecutionHandler< ], }; - // TODO would be nice to add the action name here, but it's not available - const actionLabel = `${actionTypeId}:${action.id}`; if (supportsEphemeralTasks && ephemeralActionsToSchedule > 0) { ephemeralActionsToSchedule--; try { @@ -204,39 +195,13 @@ export function createExecutionHandler< await actionsClient.enqueueExecution(enqueueOptions); } - const event = createAlertEventLogRecordObject({ - ruleId, - ruleType: ruleType as UntypedNormalizedRuleType, - consumer: ruleConsumer, - action: EVENT_LOG_ACTIONS.executeAction, - executionId, - spaceId, - instanceId: alertId, - group: actionGroup, - subgroup: actionSubgroup, - ruleName, - savedObjects: [ - { - type: 'alert', - id: ruleId, - typeId: ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, - { - type: 'action', - id: action.id, - typeId: actionTypeId, - }, - ], - ...namespace, - message: `alert: ${ruleLabel} instanceId: '${alertId}' scheduled ${ - actionSubgroup - ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` - : `actionGroup: '${actionGroup}'` - } action: ${actionLabel}`, + alertingEventLogger.logAction({ + id: action.id, + typeId: actionTypeId, + alertId, + alertGroup: actionGroup, + alertSubgroup: actionSubgroup, }); - - eventLogger.logEvent(event); } }; } diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 861f1a4bbec919..5e4594cda6c04c 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -5,14 +5,8 @@ * 2.0. */ -import { isNil } from 'lodash'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; -import { - Rule, - RuleExecutionStatusWarningReasons, - RuleTypeParams, - RecoveredActionGroup, -} from '../../common'; +import { Rule, RuleTypeParams, RecoveredActionGroup } from '../../common'; import { getDefaultRuleMonitoring } from './task_runner'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { EVENT_LOG_ACTIONS } from '../plugin'; @@ -108,6 +102,8 @@ export const ruleType: jest.Mocked = { recoveryActionGroup: RecoveredActionGroup, executor: jest.fn(), producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', }; export const mockRunNowResponse = { @@ -182,178 +178,45 @@ export const mockTaskInstance = () => ({ ownerId: null, }); -export const generateAlertSO = (id: string) => ({ - id, - rel: 'primary', - type: 'alert', - type_id: RULE_TYPE_ID, -}); +export const generateAlertOpts = ({ action, group, subgroup, state, id }: GeneratorParams = {}) => { + id = id ?? '1'; + let message: string = ''; + switch (action) { + case EVENT_LOG_ACTIONS.newInstance: + message = `test:1: 'rule-name' created new alert: '${id}'`; + break; + case EVENT_LOG_ACTIONS.activeInstance: + message = subgroup + ? `test:1: 'rule-name' active alert: '${id}' in actionGroup(subgroup): 'default(${subgroup})'` + : `test:1: 'rule-name' active alert: '${id}' in actionGroup: 'default'`; + break; + case EVENT_LOG_ACTIONS.recoveredInstance: + message = `test:1: 'rule-name' alert '${id}' has recovered`; + break; + } + return { + action, + id, + message, + state, + ...(group ? { group } : {}), + ...(subgroup ? { subgroup } : {}), + }; +}; -export const generateActionSO = (id: string) => ({ +export const generateActionOpts = ({ + subgroup, id, - namespace: undefined, - type: 'action', - type_id: 'action', -}); - -export const generateEventLog = ({ - action, - task, - duration, - consumer, - start, - end, - outcome, - reason, - instanceId, - actionSubgroup, - actionGroupId, - actionId, - status, - numberOfTriggeredActions, - numberOfGeneratedActions, - numberOfActiveAlerts, - numberOfRecoveredAlerts, - numberOfNewAlerts, - savedObjects = [generateAlertSO('1')], + alertGroup, + alertId, }: GeneratorParams = {}) => ({ - ...(status === 'error' && { - error: { - message: generateErrorMessage(String(reason)), - }, - }), - event: { - action, - ...(!isNil(duration) && { duration }), - ...(start && { start }), - ...(end && { end }), - ...(outcome && { outcome }), - ...(reason && { reason }), - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - ...(consumer && { consumer }), - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - ...((!isNil(numberOfTriggeredActions) || !isNil(numberOfGeneratedActions)) && { - metrics: { - number_of_triggered_actions: numberOfTriggeredActions, - number_of_generated_actions: numberOfGeneratedActions, - number_of_active_alerts: numberOfActiveAlerts ?? 0, - number_of_new_alerts: numberOfNewAlerts ?? 0, - number_of_recovered_alerts: numberOfRecoveredAlerts ?? 0, - total_number_of_alerts: - ((numberOfActiveAlerts ?? 0) as number) + - ((numberOfRecoveredAlerts ?? 0) as number), - number_of_searches: 3, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - }), - }, - rule_type_id: 'test', - }, - }, - ...((actionSubgroup || actionGroupId || instanceId || status) && { - alerting: { - ...(actionSubgroup && { action_subgroup: actionSubgroup }), - ...(actionGroupId && { action_group_id: actionGroupId }), - ...(instanceId && { instance_id: instanceId }), - ...(status && { status }), - }, - }), - saved_objects: savedObjects, - space_ids: ['default'], - ...(task && { - task: { - schedule_delay: 0, - scheduled: DATE_1970, - }, - }), - }, - message: generateMessage({ - action, - instanceId, - actionGroupId, - actionSubgroup, - reason, - status, - actionId, - }), - rule: { - category: 'test', - id: '1', - license: 'basic', - ...(hasRuleName({ action, status }) && { name: RULE_NAME }), - ruleset: 'alerts', - }, + id: id ?? '1', + typeId: 'action', + alertId: alertId ?? '1', + alertGroup: alertGroup ?? 'default', + ...(subgroup ? { alertSubgroup: subgroup } : {}), }); -const generateMessage = ({ - action, - instanceId, - actionGroupId, - actionSubgroup, - actionId, - reason, - status, -}: GeneratorParams) => { - if (action === EVENT_LOG_ACTIONS.executeStart) { - return `rule execution start: "${mockTaskInstance().params.alertId}"`; - } - - if (action === EVENT_LOG_ACTIONS.newInstance) { - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' created new alert: '${instanceId}'`; - } - - if (action === EVENT_LOG_ACTIONS.activeInstance) { - if (actionSubgroup) { - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' active alert: '${instanceId}' in actionGroup(subgroup): 'default(${actionSubgroup})'`; - } - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' active alert: '${instanceId}' in actionGroup: '${actionGroupId}'`; - } - - if (action === EVENT_LOG_ACTIONS.recoveredInstance) { - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' alert '${instanceId}' has recovered`; - } - - if (action === EVENT_LOG_ACTIONS.executeAction) { - if (actionSubgroup) { - return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup(subgroup): 'default(${actionSubgroup})' action: action:${actionId}`; - } - return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`; - } - - if (action === EVENT_LOG_ACTIONS.execute) { - if (status === 'error' && reason === 'execute') { - return `rule execution failure: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`; - } - if (status === 'error') { - return `${RULE_TYPE_ID}:${RULE_ID}: execution failed`; - } - if (actionGroupId === 'recovered') { - return `rule-name' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`; - } - if ( - status === 'warning' && - reason === RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS - ) { - return `The maximum number of actions for this rule type was reached; excess actions were not triggered.`; - } - return `rule executed: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`; - } -}; - -const generateErrorMessage = (reason: string) => { - if (reason === 'disabled') { - return 'Rule failed to execute because rule ran after it was disabled.'; - } - return GENERIC_ERROR_MESSAGE; -}; - export const generateRunnerResult = ({ successRatio = 1, history = Array(false), @@ -424,6 +287,3 @@ export const generateAlertInstance = ({ id, duration, start }: GeneratorParams = }, }, }); -const hasRuleName = ({ action, status }: GeneratorParams) => { - return action !== 'execute-start' && status !== 'error'; -}; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 5318988e697c68..7d95f63f3c43c2 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -45,9 +45,8 @@ import { ExecuteOptions } from '@kbn/actions-plugin/server/create_execute_functi import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; import moment from 'moment'; import { - generateActionSO, - generateAlertSO, - generateEventLog, + generateAlertOpts, + generateActionOpts, mockDate, mockedRuleTypeSavedObject, mockRunNowResponse, @@ -71,6 +70,11 @@ import { EVENT_LOG_ACTIONS } from '../plugin'; import { IN_MEMORY_METRICS } from '../monitoring'; import { translations } from '../constants/translations'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import { + AlertingEventLogger, + RuleContextOpts, +} from '../lib/alerting_event_logger/alerting_event_logger'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -80,17 +84,30 @@ jest.mock('../lib/wrap_scoped_cluster_client', () => ({ createWrappedScopedClusterClientFactory: jest.fn(), })); +jest.mock('../lib/alerting_event_logger/alerting_event_logger'); + let fakeTimer: sinon.SinonFakeTimers; const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); +const alertingEventLogger = alertingEventLoggerMock.create(); describe('Task Runner', () => { let mockedTaskInstance: ConcreteTaskInstance; + let alertingEventLoggerInitializer: RuleContextOpts; beforeAll(() => { fakeTimer = sinon.useFakeTimers(); mockedTaskInstance = mockTaskInstance(); + + alertingEventLoggerInitializer = { + consumer: mockedTaskInstance.params.consumer, + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleId: mockedTaskInstance.params.alertId, + ruleType, + spaceId: mockedTaskInstance.params.spaceId, + taskScheduledAt: mockedTaskInstance.scheduledAt, + }; }); afterAll(() => fakeTimer.restore()); @@ -186,6 +203,9 @@ describe('Task Runner', () => { ); mockedRuleTypeSavedObject.monitoring!.execution.history = []; mockedRuleTypeSavedObject.monitoring!.execution.calculated_metrics.success_ratio = 0; + + alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); + (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); }); test('successfully executes the task', async () => { @@ -201,10 +221,13 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); expect(runnerResult).toEqual(generateRunnerResult({ state: true, history: [true] })); + expect(ruleType.executor).toHaveBeenCalledTimes(1); const call = ruleType.executor.mock.calls[0][0]; expect(call.params).toEqual({ bar: true }); @@ -247,16 +270,7 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenCalledWith( - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ status: 'ok' }); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update @@ -309,6 +323,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); @@ -331,66 +347,38 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"triggeredActionsStatus":"complete"}' ); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + generatedActions: 1, + newAlerts: 1, + triggeredActions: 1, + status: 'active', + logAlert: 2, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionSubgroup: 'subDefault', - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + subgroup: 'subDefault', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - actionSubgroup: 'subDefault', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - actionGroupId: 'default', - instanceId: '1', - actionSubgroup: 'subDefault', - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - consumer: 'bar', - actionId: '1', + group: 'default', + subgroup: 'subDefault', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - task: true, - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 1, + generateActionOpts({ subgroup: 'subDefault' }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -417,6 +405,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, muteAll: true, @@ -445,53 +435,29 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"triggeredActionsStatus":"complete"}' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + newAlerts: 1, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - task: true, - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -536,6 +502,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, muteAll, @@ -588,6 +556,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, mutedInstanceIds: ['2'], @@ -666,6 +636,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, throttle: '1d', @@ -709,6 +681,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, mutedInstanceIds: ['2'], @@ -765,6 +739,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -773,46 +749,25 @@ describe('Task Runner', () => { await taskRunner.run(); expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + status: 'active', + logAlert: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: MOCK_DURATION, - start: DATE_1969, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 1, - task: true, - consumer: 'bar', + group: 'default', + state: { start: DATE_1969, duration: MOCK_DURATION, bar: false }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test.each(ephemeralTestParams)( - 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert alert state has changed %s', + 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert state has changed %s', async (nameExtension, customTaskRunnerFactoryInitializerParams, enqueueFunction) => { customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( true @@ -852,28 +807,34 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; await taskRunner.run(); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - task: true, - consumer: 'bar', + testAlertingEventLogCalls({ + activeAlerts: 1, + triggeredActions: 1, + generatedActions: 1, + status: 'active', + logAlert: 1, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + group: 'default', + state: { bar: false }, }) ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } @@ -927,6 +888,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -934,21 +897,27 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - task: true, - consumer: 'bar', + testAlertingEventLogCalls({ + activeAlerts: 1, + triggeredActions: 1, + generatedActions: 1, + status: 'active', + logAlert: 1, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + state: { bar: false }, + group: 'default', + subgroup: 'subgroup1', }) ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 1, + generateActionOpts({ subgroup: 'subgroup1' }) + ); expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); @@ -984,6 +953,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); await taskRunner.run(); @@ -1010,65 +981,33 @@ describe('Task Runner', () => { expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(enqueueFunction).toHaveBeenCalledWith(generateEnqueueFunctionInput()); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + newAlerts: 1, + triggeredActions: 1, + generatedActions: 1, + status: 'active', + logAlert: 2, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - actionGroupId: 'default', - instanceId: '1', - actionId: '1', - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - task: true, - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -1126,6 +1065,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1153,76 +1094,40 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' ); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 2, + generatedActions: 2, + status: 'active', + logAlert: 2, + logAction: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - duration: '64800000000000', - instanceId: '2', - start: '1969-12-31T06:00:00.000Z', - end: DATE_1970, - consumer: 'bar', + id: '2', + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + duration: '64800000000000', + end: DATE_1970, + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: MOCK_DURATION, - start: DATE_1969, - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - actionGroupId: 'default', - instanceId: '1', - actionId: '1', - consumer: 'bar', - }) - ); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('2')], - actionGroupId: 'recovered', - instanceId: '2', - actionId: '2', - consumer: 'bar', + group: 'default', + state: { bar: false, start: DATE_1969, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 6, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 2, - numberOfGeneratedActions: 2, - numberOfActiveAlerts: 1, - numberOfRecoveredAlerts: 1, - task: true, - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 2, + generateActionOpts({ id: '2', alertId: '2', alertGroup: 'recovered' }) ); expect(enqueueFunction).toHaveBeenCalledTimes(2); @@ -1234,7 +1139,6 @@ describe('Task Runner', () => { test.each(ephemeralTestParams)( "should skip alertInstances which weren't active on the previous execution %s", async (nameExtension, customTaskRunnerFactoryInitializerParams, enqueueFunction) => { - const alertId = 'e558aaad-fd81-46d2-96fc-3bd8fc3dc03f'; customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( true ); @@ -1270,13 +1174,12 @@ describe('Task Runner', () => { '2': { meta: {}, state: { bar: false } }, }, }, - params: { - alertId, - }, }, customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1284,24 +1187,32 @@ describe('Task Runner', () => { const logger = customTaskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledWith( - `rule test:${alertId}: '${RULE_NAME}' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + `rule test:1: '${RULE_NAME}' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 3, - `rule test:${alertId}: '${RULE_NAME}' has 1 recovered alerts: [\"2\"]` + `rule test:1: '${RULE_NAME}' has 1 recovered alerts: [\"2\"]` ); expect(logger.debug).nthCalledWith( 4, - `ruleRunStatus for test:${alertId}: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` + `ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` ); expect(logger.debug).nthCalledWith( 5, - `ruleRunMetrics for test:${alertId}: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}` + `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}` ); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); + testAlertingEventLogCalls({ + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 2, + generatedActions: 2, + status: 'active', + logAlert: 2, + logAction: 2, + }); + expect(enqueueFunction).toHaveBeenCalledTimes(2); expect((enqueueFunction as jest.Mock).mock.calls[1][0].id).toEqual('2'); expect((enqueueFunction as jest.Mock).mock.calls[0][0].id).toEqual('1'); @@ -1359,6 +1270,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, actions: [ @@ -1384,8 +1297,20 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); expect(runnerResult.state.alertInstances).toEqual(generateAlertInstance()); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); + testAlertingEventLogCalls({ + ruleContext: { + ...alertingEventLoggerInitializer, + ruleType: ruleTypeWithCustomRecovery, + }, + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 2, + generatedActions: 2, + status: 'active', + logAlert: 2, + logAction: 2, + }); + expect(enqueueFunction).toHaveBeenCalledTimes(2); expect(enqueueFunction).toHaveBeenCalledWith(generateEnqueueFunctionInput()); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); @@ -1436,6 +1361,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1443,55 +1370,37 @@ describe('Task Runner', () => { generateAlertInstance({ id: 1, duration: MOCK_DURATION, start: DATE_1969 }) ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 0, + generatedActions: 2, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - actionGroupId: 'default', - duration: '64800000000000', - instanceId: '2', - start: '1969-12-31T06:00:00.000Z', - end: DATE_1970, - consumer: 'bar', + id: '2', + group: 'default', + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + duration: '64800000000000', + end: DATE_1970, + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: MOCK_DURATION, - start: DATE_1969, - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { bar: false, start: DATE_1969, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 2, - numberOfActiveAlerts: 1, - numberOfRecoveredAlerts: 1, - task: true, - consumer: 'bar', - }) - ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1515,6 +1424,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1532,6 +1443,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -1560,6 +1473,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ ...SAVED_OBJECT, @@ -1590,6 +1505,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValueOnce(mockedRuleTypeSavedObject); rulesClient.get.mockResolvedValueOnce({ @@ -1626,6 +1542,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -1633,28 +1550,13 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - reason: 'execute', - task: true, - status: 'error', - consumer: 'bar', - }) - ); + + testAlertingEventLogCalls({ + status: 'error', + errorReason: 'execute', + executionStatus: 'failed', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1669,6 +1571,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); @@ -1676,28 +1579,13 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'decrypt', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'decrypt', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1712,6 +1600,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1720,28 +1609,12 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'license', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + status: 'error', + errorReason: 'license', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1756,6 +1629,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1764,20 +1638,13 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'unknown', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'unknown', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1792,6 +1659,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1799,20 +1667,13 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'read', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'read', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1831,6 +1692,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -1868,6 +1730,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1897,6 +1760,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1927,6 +1791,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1950,6 +1815,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1975,6 +1841,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -2022,6 +1889,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2030,76 +1899,53 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 2, + newAlerts: 2, + status: 'active', + logAlert: 4, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.newInstance, + group: 'default', + state: { + start: DATE_1970, + duration: '0', + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ + id: '2', action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { + start: DATE_1970, + duration: '0', + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 3, - generateEventLog({ - duration: '0', - start: DATE_1970, - action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '2', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 4, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '2', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 6, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 2, - numberOfNewAlerts: 2, - task: true, - consumer: 'bar', + id: '2', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2149,6 +1995,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2157,51 +2005,26 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 2, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: MOCK_DURATION, - start: DATE_1969, - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { bar: false, start: DATE_1969, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: '64800000000000', - start: '1969-12-31T06:00:00.000Z', - instanceId: '2', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 2, - task: true, - consumer: 'bar', + id: '2', + group: 'default', + state: { bar: false, start: '1969-12-31T06:00:00.000Z', duration: '64800000000000' }, }) ); @@ -2246,6 +2069,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2254,48 +2079,29 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 2, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { bar: false }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - consumer: 'bar', - instanceId: '2', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - consumer: 'bar', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 2, - task: true, + id: '2', + group: 'default', + state: { bar: false }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2332,6 +2138,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2340,53 +2148,32 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + recoveredAlerts: 2, + status: 'ok', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - duration: MOCK_DURATION, - start: DATE_1969, - end: DATE_1970, - consumer: 'bar', - instanceId: '1', + state: { bar: false, start: DATE_1969, end: DATE_1970, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - duration: '64800000000000', - start: '1969-12-31T06:00:00.000Z', - end: DATE_1970, - consumer: 'bar', - instanceId: '2', + id: '2', + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + end: DATE_1970, + duration: '64800000000000', + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'ok', - consumer: 'bar', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfRecoveredAlerts: 2, - task: true, - }) - ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2425,6 +2212,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2433,47 +2222,29 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + recoveredAlerts: 2, + status: 'ok', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - consumer: 'bar', - instanceId: '1', + state: { bar: false }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - consumer: 'bar', - instanceId: '2', + id: '2', + state: { + bar: false, + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'ok', - consumer: 'bar', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfRecoveredAlerts: 2, - task: true, - }) - ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2493,6 +2264,8 @@ describe('Task Runner', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -2539,17 +2312,10 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + status: 'ok', + }); + expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update ).toHaveBeenCalledWith(...generateSavedObjectParams({})); @@ -2570,6 +2336,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ ...SAVED_OBJECT, @@ -2579,28 +2347,14 @@ describe('Task Runner', () => { expect(runnerResult.state.previousStartedAt?.toISOString()).toBe(state.previousStartedAt); expect(runnerResult.schedule).toStrictEqual(mockedTaskInstance.schedule); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - errorMessage: 'Rule failed to execute because rule ran after it was disabled.', - action: EVENT_LOG_ACTIONS.execute, - consumer: 'bar', - outcome: 'failure', - task: true, - reason: 'disabled', - status: 'error', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'disabled', + errorMessage: `Rule failed to execute because rule ran after it was disabled.`, + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2611,6 +2365,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -2625,6 +2380,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); ruleType.executor.mockImplementation( @@ -2651,6 +2408,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); @@ -2684,6 +2443,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -2767,6 +2528,7 @@ describe('Task Runner', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); const runnerResult = await taskRunner.run(); @@ -2805,87 +2567,40 @@ describe('Task Runner', () => { 'Rule "1" skipped scheduling action "4" because the maximum number of allowed actions has been reached.' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(7); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + newAlerts: 1, + activeAlerts: 1, + triggeredActions: actionsConfigMap.default.max, + generatedActions: mockActions.length, + status: 'warning', + errorReason: `maxExecutableActions`, + logAlert: 2, + logAction: 3, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - actionGroupId: 'default', - instanceId: '1', - actionId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('2')], - actionGroupId: 'default', - instanceId: '1', - actionId: '2', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 6, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('3')], - actionGroupId: 'default', - instanceId: '1', - actionId: '3', - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 2, + generateActionOpts({ id: '2' }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 7, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'warning', - numberOfTriggeredActions: actionsConfigMap.default.max, - numberOfGeneratedActions: mockActions.length, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, - task: true, - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 3, + generateActionOpts({ id: '3' }) ); }); @@ -2966,6 +2681,7 @@ describe('Task Runner', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); const runnerResult = await taskRunner.run(); @@ -3017,8 +2733,16 @@ describe('Task Runner', () => { 'Rule "1" skipped scheduling action "1" because the maximum number of allowed actions for connector type .server-log has been reached.' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(11); + testAlertingEventLogCalls({ + newAlerts: 2, + activeAlerts: 2, + generatedActions: 10, + triggeredActions: 5, + status: 'warning', + errorReason: `maxExecutableActions`, + logAlert: 4, + logAction: 5, + }); }); test('increments monitoring metrics after execution', async () => { @@ -3028,6 +2752,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', @@ -3067,4 +2793,125 @@ describe('Task Runner', () => { expect(inMemoryMetrics.increment.mock.calls[4][0]).toBe(IN_MEMORY_METRICS.RULE_FAILURES); expect(inMemoryMetrics.increment.mock.calls[5][0]).toBe(IN_MEMORY_METRICS.RULE_TIMEOUTS); }); + + function testAlertingEventLogCalls({ + ruleContext = alertingEventLoggerInitializer, + activeAlerts = 0, + newAlerts = 0, + recoveredAlerts = 0, + triggeredActions = 0, + generatedActions = 0, + status, + errorReason, + errorMessage = 'GENERIC ERROR MESSAGE', + executionStatus = 'succeeded', + setRuleName = true, + logAlert = 0, + logAction = 0, + }: { + status: string; + ruleContext?: RuleContextOpts; + activeAlerts?: number; + newAlerts?: number; + recoveredAlerts?: number; + triggeredActions?: number; + generatedActions?: number; + executionStatus?: 'succeeded' | 'failed' | 'not-reached'; + setRuleName?: boolean; + logAlert?: number; + logAction?: number; + errorReason?: string; + errorMessage?: string; + }) { + expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); + expect(alertingEventLogger.start).toHaveBeenCalled(); + if (setRuleName) { + expect(alertingEventLogger.setRuleName).toHaveBeenCalledWith(mockedRuleTypeSavedObject.name); + } else { + expect(alertingEventLogger.setRuleName).not.toHaveBeenCalled(); + } + expect(alertingEventLogger.getStartAndDuration).toHaveBeenCalled(); + if (status === 'error') { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: null, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + error: { + message: errorMessage, + reason: errorReason, + }, + }, + }); + } else if (status === 'warning') { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + totalSearchDurationMs: 23423, + triggeredActionsStatus: 'partial', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + warning: { + message: `The maximum number of actions for this rule type was reached; excess actions were not triggered.`, + reason: errorReason, + }, + }, + }); + } else { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + totalSearchDurationMs: 23423, + triggeredActionsStatus: 'complete', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + }, + }); + } + + if (executionStatus === 'succeeded') { + expect(alertingEventLogger.setExecutionSucceeded).toHaveBeenCalledWith( + `rule executed: test:1: 'rule-name'` + ); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + } else if (executionStatus === 'failed') { + expect(alertingEventLogger.setExecutionFailed).toHaveBeenCalledWith( + `rule execution failure: test:1: 'rule-name'`, + errorMessage + ); + expect(alertingEventLogger.setExecutionSucceeded).not.toHaveBeenCalled(); + } else if (executionStatus === 'not-reached') { + expect(alertingEventLogger.setExecutionSucceeded).not.toHaveBeenCalled(); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + } + + if (logAlert > 0) { + expect(alertingEventLogger.logAlert).toHaveBeenCalledTimes(logAlert); + } else { + expect(alertingEventLogger.logAlert).not.toHaveBeenCalled(); + } + + if (logAction > 0) { + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(logAction); + } else { + expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); + } + expect(alertingEventLogger.logTimeout).not.toHaveBeenCalled(); + } }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index afed1f4c9ad09a..6cd6b73b9539ea 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -5,19 +5,14 @@ * 2.0. */ import apm from 'elastic-apm-node'; -import { cloneDeep, mapValues, omit, pickBy, set, without } from 'lodash'; +import { cloneDeep, mapValues, omit, pickBy, without } from 'lodash'; import type { Request } from '@hapi/hapi'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import uuid from 'uuid'; import { addSpaceIdToPath } from '@kbn/spaces-plugin/server'; import { KibanaRequest, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance, throwUnrecoverableError } from '@kbn/task-manager-plugin/server'; -import { - IEvent, - SAVED_OBJECT_REL_PRIMARY, - millisToNanos, - nanosToMillis, -} from '@kbn/event-log-plugin/server'; +import { millisToNanos, nanosToMillis } from '@kbn/event-log-plugin/server'; import { TaskRunnerContext } from './task_runner_factory'; import { createExecutionHandler, ExecutionHandler } from './create_execution_handler'; import { Alert, createAlertFactory } from '../alert'; @@ -62,10 +57,6 @@ import { } from '../../common'; import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; -import { - createAlertEventLogRecordObject, - Event, -} from '../lib/create_alert_event_log_record_object'; import { InMemoryMetrics, IN_MEMORY_METRICS } from '../monitoring'; import { GenerateNewAndRecoveredAlertEventsParams, @@ -79,13 +70,11 @@ import { } from './types'; import { IExecutionStatusAndMetrics } from '../lib/rule_execution_status'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; -// 1,000,000 nanoseconds in 1 millisecond -const Millis2Nanos = 1000 * 1000; - export const getDefaultRuleMonitoring = (): RuleMonitoring => ({ execution: { history: [], @@ -107,7 +96,6 @@ export class TaskRunner< private context: TaskRunnerContext; private logger: Logger; private taskInstance: RuleTaskInstance; - private ruleName: string | null; private ruleConsumer: string | null; private ruleType: NormalizedRuleType< Params, @@ -121,6 +109,7 @@ export class TaskRunner< private readonly executionId: string; private readonly ruleTypeRegistry: RuleTypeRegistry; private readonly inMemoryMetrics: InMemoryMetrics; + private alertingEventLogger: AlertingEventLogger; private usageCounter?: UsageCounter; private searchAbortController: AbortController; private cancelled: boolean; @@ -143,7 +132,6 @@ export class TaskRunner< this.logger = context.logger; this.usageCounter = context.usageCounter; this.ruleType = ruleType; - this.ruleName = null; this.ruleConsumer = null; this.taskInstance = taskInstanceToAlertTaskInstance(taskInstance); this.ruleTypeRegistry = context.ruleTypeRegistry; @@ -151,6 +139,7 @@ export class TaskRunner< this.cancelled = false; this.executionId = uuid.v4(); this.inMemoryMetrics = inMemoryMetrics; + this.alertingEventLogger = new AlertingEventLogger(this.context.eventLogger); } private async getDecryptedAttributes( @@ -231,7 +220,7 @@ export class TaskRunner< spaceId, ruleType: this.ruleType, kibanaBaseUrl, - eventLogger: this.context.eventLogger, + alertingEventLogger: this.alertingEventLogger, request, ruleParams, supportsEphemeralTasks: this.context.supportsEphemeralTasks, @@ -321,8 +310,7 @@ export class TaskRunner< rule: SanitizedRule, params: Params, executionHandler: ExecutionHandler, - spaceId: string, - event: Event + spaceId: string ): Promise { const { alertTypeId, @@ -358,7 +346,6 @@ export class TaskRunner< const originalAlerts = cloneDeep(alerts); const originalAlertIds = new Set(Object.keys(originalAlerts)); - const eventLogger = this.context.eventLogger; const ruleLabel = `${this.ruleType.id}:${ruleId}: '${name}'`; const scopedClusterClient = this.context.elasticsearch.client.asScoped(fakeRequest); @@ -440,22 +427,15 @@ export class TaskRunner< }) ); } catch (err) { - event.message = `rule execution failure: ${ruleLabel}`; - event.error = event.error || {}; - event.error.message = err.message; - event.event = event.event || {}; - event.event.outcome = 'failure'; + this.alertingEventLogger.setExecutionFailed( + `rule execution failure: ${ruleLabel}`, + err.message + ); throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Execute, err); } - event.message = `rule executed: ${ruleLabel}`; - event.event = event.event || {}; - event.event.outcome = 'success'; - event.rule = { - ...event.rule, - name: rule.name, - }; + this.alertingEventLogger.setExecutionSucceeded(`rule executed: ${ruleLabel}`); const ruleRunMetricsStore = new RuleRunMetricsStore(); @@ -488,17 +468,11 @@ export class TaskRunner< if (this.shouldLogAndScheduleActionsForAlerts()) { generateNewAndRecoveredAlertEvents({ - eventLogger, - executionId: this.executionId, + alertingEventLogger: this.alertingEventLogger, originalAlerts, currentAlerts: alertsWithScheduledActions, recoveredAlerts, - ruleId, ruleLabel, - namespace, - ruleType, - rule, - spaceId, ruleRunMetricsStore, }); } @@ -584,8 +558,7 @@ export class TaskRunner< private async validateAndExecuteRule( fakeRequest: KibanaRequest, apiKey: RawRule['apiKey'], - rule: SanitizedRule, - event: Event + rule: SanitizedRule ) { const { params: { alertId: ruleId, spaceId }, @@ -604,10 +577,10 @@ export class TaskRunner< rule.params, fakeRequest ); - return this.executeRule(fakeRequest, rule, validatedParams, executionHandler, spaceId, event); + return this.executeRule(fakeRequest, rule, validatedParams, executionHandler, spaceId); } - private async loadRuleAttributesAndRun(event: Event): Promise> { + private async loadRuleAttributesAndRun(): Promise> { const { params: { alertId: ruleId, spaceId }, } = this.taskInstance; @@ -657,7 +630,7 @@ export class TaskRunner< throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Read, err); } - this.ruleName = rule.name; + this.alertingEventLogger.setRuleName(rule.name); try { this.ruleTypeRegistry.ensureRuleTypeEnabled(rule.alertTypeId); @@ -674,7 +647,7 @@ export class TaskRunner< return { monitoring: asOk(rule.monitoring), stateWithMetrics: await promiseResult( - this.validateAndExecuteRule(fakeRequest, apiKey, rule, event) + this.validateAndExecuteRule(fakeRequest, apiKey, rule) ), schedule: asOk( // fetch the rule again to ensure we return the correct schedule as it may have @@ -716,46 +689,21 @@ export class TaskRunner< this.logger.debug(`executing rule ${this.ruleType.id}:${ruleId} at ${runDateString}`); const namespace = this.context.spaceIdToNamespace(spaceId); - const eventLogger = this.context.eventLogger; - const scheduleDelay = runDate.getTime() - this.taskInstance.scheduledAt.getTime(); - const event = createAlertEventLogRecordObject({ + this.alertingEventLogger.initialize({ ruleId, ruleType: this.ruleType as UntypedNormalizedRuleType, consumer: this.ruleConsumer!, - action: EVENT_LOG_ACTIONS.execute, - namespace, spaceId, executionId: this.executionId, - task: { - scheduled: this.taskInstance.scheduledAt.toISOString(), - scheduleDelay: Millis2Nanos * scheduleDelay, - }, - savedObjects: [ - { - id: ruleId, - type: 'alert', - typeId: this.ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, - ], - }); - - eventLogger.startTiming(event); - - const startEvent = cloneDeep({ - ...event, - event: { - ...event.event, - action: EVENT_LOG_ACTIONS.executeStart, - }, - message: `rule execution start: "${ruleId}"`, + taskScheduledAt: this.taskInstance.scheduledAt, + ...(namespace ? { namespace } : {}), }); - eventLogger.logEvent(startEvent); + this.alertingEventLogger.start(); const { stateWithMetrics, schedule, monitoring } = await errorAsRuleTaskRunResult( - this.loadRuleAttributesAndRun(event) + this.loadRuleAttributesAndRun() ); const ruleMonitoring = @@ -772,10 +720,6 @@ export class TaskRunner< (ruleRunStateWithMetrics) => executionStatusFromState(ruleRunStateWithMetrics, runDate), (err: ElasticsearchError) => executionStatusFromError(err, runDate) ); - // set the executionStatus date to same as event, if it's set - if (event.event?.start) { - executionStatus.lastExecutionDate = new Date(event.event.start); - } if (apm.currentTransaction) { if (executionStatus.status === 'ok' || executionStatus.status === 'active') { @@ -794,91 +738,27 @@ export class TaskRunner< ); } - eventLogger.stopTiming(event); - set(event, 'kibana.alerting.status', executionStatus.status); - - if (this.ruleConsumer) { - set(event, 'kibana.alert.rule.consumer', this.ruleConsumer); - } + this.alertingEventLogger.done({ status: executionStatus, metrics: executionMetrics }); const monitoringHistory: RuleMonitoringHistory = { success: true, timestamp: +new Date(), }; - // Copy duration into execution status if available - if (null != event.event?.duration) { - executionStatus.lastDuration = nanosToMillis(event.event?.duration); + // set start and duration based on event log + const { start, duration } = this.alertingEventLogger.getStartAndDuration(); + if (null != start) { + executionStatus.lastExecutionDate = start; + } + if (null != duration) { + executionStatus.lastDuration = nanosToMillis(duration); monitoringHistory.duration = executionStatus.lastDuration; } // if executionStatus indicates an error, fill in fields in // event from it if (executionStatus.error) { - set(event, 'event.reason', executionStatus.error?.reason || 'unknown'); - set(event, 'event.outcome', 'failure'); - set(event, 'error.message', event?.error?.message || executionStatus.error.message); - if (!event.message) { - event.message = `${this.ruleType.id}:${ruleId}: execution failed`; - } monitoringHistory.success = false; - } else { - if (executionStatus.warning) { - set(event, 'event.reason', executionStatus.warning?.reason || 'unknown'); - set(event, 'message', executionStatus.warning?.message || event?.message); - } - if (executionMetrics) { - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_triggered_actions', - executionMetrics.numberOfTriggeredActions - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_generated_actions', - executionMetrics.numberOfGeneratedActions - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_active_alerts', - executionMetrics.numberOfActiveAlerts - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_new_alerts', - executionMetrics.numberOfNewAlerts - ); - set( - event, - 'kibana.alert.rule.execution.metrics.total_number_of_alerts', - (executionMetrics.numberOfActiveAlerts ?? 0) + - (executionMetrics.numberOfRecoveredAlerts ?? 0) - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_recovered_alerts', - executionMetrics.numberOfRecoveredAlerts - ); - } - } - - // Copy search stats into event log - if (executionMetrics) { - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_searches', - executionMetrics.numSearches ?? 0 - ); - set( - event, - 'kibana.alert.rule.execution.metrics.es_search_duration_ms', - executionMetrics.esSearchDurationMs ?? 0 - ); - set( - event, - 'kibana.alert.rule.execution.metrics.total_search_duration_ms', - executionMetrics.totalSearchDurationMs ?? 0 - ); } ruleMonitoring.execution.history.push(monitoringHistory); @@ -887,8 +767,6 @@ export class TaskRunner< ...getExecutionDurationPercentiles(ruleMonitoring), }; - eventLogger.logEvent(event); - if (!this.cancelled) { this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_EXECUTIONS); if (executionStatus.error) { @@ -982,48 +860,7 @@ export class TaskRunner< ); this.searchAbortController.abort(); - const eventLogger = this.context.eventLogger; - const event: IEvent = { - event: { - action: EVENT_LOG_ACTIONS.executeTimeout, - kind: 'alert', - category: [this.ruleType.producer], - }, - message: `rule: ${this.ruleType.id}:${ruleId}: '${ - this.ruleName ?? '' - }' execution cancelled due to timeout - exceeded rule type timeout of ${ - this.ruleType.ruleTaskTimeout - }`, - kibana: { - alert: { - rule: { - ...(this.ruleConsumer ? { consumer: this.ruleConsumer } : {}), - execution: { - uuid: this.executionId, - }, - rule_type_id: this.ruleType.id, - }, - }, - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: ruleId, - type_id: this.ruleType.id, - namespace, - }, - ], - space_ids: [spaceId], - }, - rule: { - id: ruleId, - license: this.ruleType.minimumLicenseRequired, - category: this.ruleType.id, - ruleset: this.ruleType.producer, - ...(this.ruleName ? { name: this.ruleName } : {}), - }, - }; - eventLogger.logEvent(event); + this.alertingEventLogger.logTimeout(); this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_TIMEOUTS); @@ -1096,16 +933,10 @@ function generateNewAndRecoveredAlertEvents< InstanceContext extends AlertInstanceContext >(params: GenerateNewAndRecoveredAlertEventsParams) { const { - eventLogger, - executionId, - ruleId, - namespace, + alertingEventLogger, currentAlerts, originalAlerts, recoveredAlerts, - rule, - ruleType, - spaceId, ruleRunMetricsStore, } = params; const originalAlertIds = Object.keys(originalAlerts); @@ -1128,14 +959,15 @@ function generateNewAndRecoveredAlertEvents< recoveredAlerts[id].getLastScheduledActions() ?? {}; const state = recoveredAlerts[id].getState(); const message = `${params.ruleLabel} alert '${id}' has recovered`; - logAlertEvent( + + alertingEventLogger.logAlert({ + action: EVENT_LOG_ACTIONS.recoveredInstance, id, - EVENT_LOG_ACTIONS.recoveredInstance, + group: actionGroup, + subgroup: actionSubgroup, message, state, - actionGroup, - actionSubgroup - ); + }); } for (const id of newIds) { @@ -1143,7 +975,14 @@ function generateNewAndRecoveredAlertEvents< currentAlerts[id].getScheduledActionOptions() ?? {}; const state = currentAlerts[id].getState(); const message = `${params.ruleLabel} created new alert: '${id}'`; - logAlertEvent(id, EVENT_LOG_ACTIONS.newInstance, message, state, actionGroup, actionSubgroup); + alertingEventLogger.logAlert({ + action: EVENT_LOG_ACTIONS.newInstance, + id, + group: actionGroup, + subgroup: actionSubgroup, + message, + state, + }); } for (const id of currentAlertIds) { @@ -1155,69 +994,14 @@ function generateNewAndRecoveredAlertEvents< ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` : `actionGroup: '${actionGroup}'` }`; - logAlertEvent( + alertingEventLogger.logAlert({ + action: EVENT_LOG_ACTIONS.activeInstance, id, - EVENT_LOG_ACTIONS.activeInstance, + group: actionGroup, + subgroup: actionSubgroup, message, state, - actionGroup, - actionSubgroup - ); - } - - function logAlertEvent( - alertId: string, - action: string, - message: string, - state: InstanceState, - group?: string, - subgroup?: string - ) { - const event: IEvent = { - event: { - action, - kind: 'alert', - category: [ruleType.producer], - ...(state?.start ? { start: state.start as string } : {}), - ...(state?.end ? { end: state.end as string } : {}), - ...(state?.duration !== undefined ? { duration: state.duration as string } : {}), - }, - kibana: { - alert: { - rule: { - consumer: rule.consumer, - execution: { - uuid: executionId, - }, - rule_type_id: ruleType.id, - }, - }, - alerting: { - instance_id: alertId, - ...(group ? { action_group_id: group } : {}), - ...(subgroup ? { action_subgroup: subgroup } : {}), - }, - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: ruleId, - type_id: ruleType.id, - namespace, - }, - ], - space_ids: [spaceId], - }, - message, - rule: { - id: rule.id, - license: ruleType.minimumLicenseRequired, - category: ruleType.id, - ruleset: ruleType.producer, - name: rule.name, - }, - }; - eventLogger.logEvent(event); + }); } } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index fdb3c8e7560030..fb2d1be3a3872c 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -14,7 +14,7 @@ import { AlertInstanceState, AlertInstanceContext, } from '../types'; -import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server'; +import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; import { TaskRunnerContext } from './task_runner_factory'; import { TaskRunner } from './task_runner'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; @@ -32,11 +32,23 @@ import { actionsMock, actionsClientMock } from '@kbn/actions-plugin/server/mocks import { alertsMock, rulesClientMock } from '../mocks'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { IEventLogger } from '@kbn/event-log-plugin/server'; -import { Rule, RecoveredActionGroup } from '../../common'; -import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; +import { + AlertingEventLogger, + RuleContextOpts, +} from '../lib/alerting_event_logger/alerting_event_logger'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; +import { + mockTaskInstance, + ruleType, + mockedRuleTypeSavedObject, + generateAlertOpts, + DATE_1970, + generateActionOpts, +} from './fixtures'; +import { EVENT_LOG_ACTIONS } from '../plugin'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -45,48 +57,29 @@ jest.mock('../lib/wrap_scoped_cluster_client', () => ({ createWrappedScopedClusterClientFactory: jest.fn(), })); -const ruleType: jest.Mocked = { - id: 'test', - name: 'My test rule', - actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - recoveryActionGroup: RecoveredActionGroup, - executor: jest.fn(), - producer: 'alerts', - cancelAlertsOnRuleTimeout: true, - ruleTaskTimeout: '5m', -}; +jest.mock('../lib/alerting_event_logger/alerting_event_logger'); let fakeTimer: sinon.SinonFakeTimers; const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); +const alertingEventLogger = alertingEventLoggerMock.create(); describe('Task Runner Cancel', () => { let mockedTaskInstance: ConcreteTaskInstance; + let alertingEventLoggerInitializer: RuleContextOpts; beforeAll(() => { fakeTimer = sinon.useFakeTimers(); - mockedTaskInstance = { - id: '', - attempts: 0, - status: TaskStatus.Running, - version: '123', - runAt: new Date(), - schedule: { interval: '10s' }, - scheduledAt: new Date(), - startedAt: new Date(), - retryAt: new Date(Date.now() + 5 * 60 * 1000), - state: {}, - taskType: 'alerting:test', - params: { - alertId: '1', - spaceId: 'default', - consumer: 'bar', - }, - ownerId: null, + mockedTaskInstance = mockTaskInstance(); + + alertingEventLoggerInitializer = { + consumer: mockedTaskInstance.params.consumer, + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleId: mockedTaskInstance.params.alertId, + ruleType, + spaceId: mockedTaskInstance.params.spaceId, + taskScheduledAt: mockedTaskInstance.scheduledAt, }; }); @@ -136,53 +129,6 @@ describe('Task Runner Cancel', () => { }, }; - const mockDate = new Date('2019-02-12T21:01:22.479Z'); - - const mockedRuleSavedObject: Rule = { - id: '1', - consumer: 'bar', - createdAt: mockDate, - updatedAt: mockDate, - throttle: null, - muteAll: false, - notifyWhen: 'onActiveAlert', - enabled: true, - alertTypeId: ruleType.id, - apiKey: '', - apiKeyOwner: 'elastic', - schedule: { interval: '10s' }, - name: 'rule-name', - tags: ['rule-', '-tags'], - createdBy: 'rule-creator', - updatedBy: 'rule-updater', - mutedInstanceIds: [], - params: { - bar: true, - }, - actions: [ - { - group: 'default', - id: '1', - actionTypeId: 'action', - params: { - foo: true, - }, - }, - { - group: RecoveredActionGroup.id, - id: '2', - actionTypeId: 'action', - params: { - isResolved: true, - }, - }, - ], - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - }, - }; - beforeEach(() => { jest.resetAllMocks(); jest @@ -208,7 +154,7 @@ describe('Task Runner Cancel', () => { taskRunnerFactoryInitializerParams.executionContext.withContext.mockImplementation((ctx, fn) => fn() ); - rulesClient.get.mockResolvedValue(mockedRuleSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -221,6 +167,8 @@ describe('Task Runner Cancel', () => { }); taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); + (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); }); test('updates rule saved object execution status and writes to event log entry when task is cancelled mid-execution', async () => { @@ -230,6 +178,7 @@ describe('Task Runner Cancel', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); @@ -242,136 +191,7 @@ describe('Task Runner Cancel', () => { `Aborting any in-progress ES searches for rule type test with id 1` ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - // execute-start event, timeout event and then an execute event because rule executors are not cancelling anything yet - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - }, - message: 'rule execution start: "1"', - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'execute-timeout', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'execute', - category: ['alerts'], - kind: 'alert', - outcome: 'success', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - metrics: { - number_of_searches: 3, - number_of_triggered_actions: 0, - number_of_generated_actions: 0, - number_of_active_alerts: 0, - number_of_new_alerts: 0, - number_of_recovered_alerts: 0, - total_number_of_alerts: 0, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - status: 'ok', - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - }, - message: `rule executed: test:1: 'rule-name'`, - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, - }); + testAlertingEventLogCalls({ status: 'ok' }); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update @@ -426,22 +246,50 @@ describe('Task Runner Cancel', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); await taskRunner.cancel(); await promise; - testActionsExecute(); + testLogger(); + testAlertingEventLogCalls({ + status: 'active', + newAlerts: 1, + activeAlerts: 1, + generatedActions: 1, + triggeredActions: 1, + logAction: 1, + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.newInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('actionsPlugin.execute is called if rule execution is cancelled but cancelAlertsOnRuleTimeout for ruleType is false', async () => { - ruleTypeRegistry.get.mockReturnValue({ + const updatedRuleType = { ...ruleType, cancelAlertsOnRuleTimeout: false, - }); + }; + ruleTypeRegistry.get.mockReturnValue(updatedRuleType); ruleType.executor.mockImplementation( async ({ services: executorServices, @@ -457,21 +305,47 @@ describe('Task Runner Cancel', () => { ); // setting cancelAlertsOnRuleTimeout for ruleType to false here const taskRunner = new TaskRunner( - { - ...ruleType, - cancelAlertsOnRuleTimeout: false, - }, + updatedRuleType, mockedTaskInstance, taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); await taskRunner.cancel(); await promise; - testActionsExecute(); + testLogger(); + testAlertingEventLogCalls({ + ruleContext: { ...alertingEventLoggerInitializer, ruleType: updatedRuleType }, + status: 'active', + activeAlerts: 1, + generatedActions: 1, + newAlerts: 1, + triggeredActions: 1, + logAlert: 2, + logAction: 1, + }); + + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.newInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -496,174 +370,15 @@ describe('Task Runner Cancel', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); await taskRunner.cancel(); await promise; - const logger = taskRunnerFactoryInitializerParams.logger; - expect(logger.debug).toHaveBeenCalledTimes(8); - expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); - expect(logger.debug).nthCalledWith( - 2, - `Cancelling rule type test with id 1 - execution exceeded rule type timeout of 5m` - ); - expect(logger.debug).nthCalledWith( - 3, - 'Aborting any in-progress ES searches for rule type test with id 1' - ); - expect(logger.debug).nthCalledWith( - 4, - `Updating rule task for test rule with id 1 - execution error due to timeout` - ); - expect(logger.debug).nthCalledWith( - 5, - `rule test:1: 'rule-name' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` - ); - expect(logger.debug).nthCalledWith( - 6, - `no scheduling of actions for rule test:1: 'rule-name': rule execution has been cancelled.` - ); - expect(logger.debug).nthCalledWith( - 7, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' - ); - expect(logger.debug).nthCalledWith( - 8, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' - ); - - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule execution start: \"1\"`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'execute-timeout', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'execute', - category: ['alerts'], - kind: 'alert', - outcome: 'success', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - metrics: { - number_of_searches: 3, - number_of_triggered_actions: 0, - number_of_generated_actions: 0, - number_of_active_alerts: 0, - number_of_recovered_alerts: 0, - number_of_new_alerts: 0, - total_number_of_alerts: 0, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - status: 'active', - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: "rule executed: test:1: 'rule-name'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, + testAlertingEventLogCalls({ + status: 'active', }); expect(mockUsageCounter.incrementCounter).toHaveBeenCalledTimes(1); @@ -673,7 +388,7 @@ describe('Task Runner Cancel', () => { }); }); - function testActionsExecute() { + function testLogger() { const logger = taskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(7); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); @@ -701,256 +416,69 @@ describe('Task Runner Cancel', () => { 7, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"triggeredActionsStatus":"complete"}' ); + } - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule execution start: "1"`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'execute-timeout', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'new-instance', - category: ['alerts'], - kind: 'alert', - duration: '0', - start: '1970-01-01T00:00:00.000Z', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - action_group_id: 'default', - instance_id: '1', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: "test:1: 'rule-name' created new alert: '1'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - namespace: undefined, - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(4, { - event: { - action: 'active-instance', - category: ['alerts'], - duration: '0', - kind: 'alert', - start: '1970-01-01T00:00:00.000Z', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - action_group_id: 'default', - instance_id: '1', - }, - saved_objects: [ - { id: '1', namespace: undefined, rel: 'primary', type: 'alert', type_id: 'test' }, - ], - space_ids: ['default'], - }, - message: "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(5, { - event: { - action: 'execute-action', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - instance_id: '1', - action_group_id: 'default', - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - { - id: '1', - type: 'action', - type_id: 'action', - }, - ], - space_ids: ['default'], - }, - message: - "alert: test:1: 'rule-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(6, { - event: { action: 'execute', category: ['alerts'], kind: 'alert', outcome: 'success' }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - metrics: { - number_of_searches: 3, - number_of_triggered_actions: 1, - number_of_generated_actions: 1, - number_of_active_alerts: 1, - number_of_new_alerts: 1, - number_of_recovered_alerts: 0, - total_number_of_alerts: 1, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - status: 'active', - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: "rule executed: test:1: 'rule-name'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', + function testAlertingEventLogCalls({ + ruleContext = alertingEventLoggerInitializer, + activeAlerts = 0, + newAlerts = 0, + recoveredAlerts = 0, + triggeredActions = 0, + generatedActions = 0, + status, + logAlert = 0, + logAction = 0, + }: { + status: string; + ruleContext?: RuleContextOpts; + activeAlerts?: number; + newAlerts?: number; + recoveredAlerts?: number; + triggeredActions?: number; + generatedActions?: number; + setRuleName?: boolean; + logAlert?: number; + logAction?: number; + }) { + expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); + expect(alertingEventLogger.start).toHaveBeenCalled(); + expect(alertingEventLogger.setRuleName).toHaveBeenCalledWith(mockedRuleTypeSavedObject.name); + expect(alertingEventLogger.getStartAndDuration).toHaveBeenCalled(); + + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + totalSearchDurationMs: 23423, + triggeredActionsStatus: 'complete', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, }, }); + + expect(alertingEventLogger.setExecutionSucceeded).toHaveBeenCalledWith( + `rule executed: test:1: 'rule-name'` + ); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + + if (logAlert > 0) { + expect(alertingEventLogger.logAlert).toHaveBeenCalledTimes(logAlert); + } else { + expect(alertingEventLogger.logAlert).not.toHaveBeenCalled(); + } + + if (logAction > 0) { + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(logAction); + } else { + expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); + } + expect(alertingEventLogger.logTimeout).toHaveBeenCalled(); } }); diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index 1f4a31fa1d9ac9..d3c6038474a387 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -8,8 +8,8 @@ import { Dictionary } from 'lodash'; import { KibanaRequest, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; -import { IEventLogger } from '@kbn/event-log-plugin/server'; import { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { ActionGroup, RuleAction, @@ -20,7 +20,6 @@ import { IntervalSchedule, RuleMonitoring, RuleTaskState, - SanitizedRule, } from '../../common'; import { Alert } from '../alert'; import { NormalizedRuleType } from '../rule_type_registry'; @@ -28,6 +27,7 @@ import { ExecutionHandler } from './create_execution_handler'; import { RawRule } from '../types'; import { ActionsConfigMap } from '../lib/get_actions_config_map'; import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; export interface RuleTaskRunResult { state: RuleTaskState; @@ -61,29 +61,11 @@ export interface GenerateNewAndRecoveredAlertEventsParams< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext > { - eventLogger: IEventLogger; - executionId: string; + alertingEventLogger: AlertingEventLogger; originalAlerts: Dictionary>; currentAlerts: Dictionary>; recoveredAlerts: Dictionary>; - ruleId: string; ruleLabel: string; - namespace: string | undefined; - ruleType: NormalizedRuleType< - RuleTypeParams, - RuleTypeParams, - RuleTypeState, - { - [x: string]: unknown; - }, - { - [x: string]: unknown; - }, - string, - string - >; - rule: SanitizedRule; - spaceId: string; ruleRunMetricsStore: RuleRunMetricsStore; } @@ -145,7 +127,7 @@ export interface CreateExecutionHandlerOptions< RecoveryActionGroupId >; logger: Logger; - eventLogger: IEventLogger; + alertingEventLogger: PublicMethodsOf; request: KibanaRequest; ruleParams: RuleTypeParams; supportsEphemeralTasks: boolean; diff --git a/x-pack/plugins/apm/server/lib/helpers/get_metric_indices.ts b/x-pack/plugins/apm/server/lib/helpers/get_metric_indices.ts new file mode 100644 index 00000000000000..61d12ba7309421 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/get_metric_indices.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from '@kbn/core/server'; +import { APMRouteHandlerResources } from '../../routes/typings'; + +export async function getMetricIndices({ + infraPlugin, + savedObjectsClient, +}: { + infraPlugin: Required; + savedObjectsClient: SavedObjectsClientContract; +}): Promise { + const infra = await infraPlugin.start(); + const metricIndices = await infra.getMetricIndices(savedObjectsClient); + + return metricIndices; +} diff --git a/x-pack/plugins/apm/server/types.ts b/x-pack/plugins/apm/server/types.ts index d3758867287489..73258ef2008fae 100644 --- a/x-pack/plugins/apm/server/types.ts +++ b/x-pack/plugins/apm/server/types.ts @@ -49,6 +49,7 @@ import { FleetSetupContract as FleetPluginSetup, FleetStartContract as FleetPluginStart, } from '@kbn/fleet-plugin/server'; +import { InfraPluginStart, InfraPluginSetup } from '@kbn/infra-plugin/server'; import { APMConfig } from '.'; import { ApmIndicesConfig } from './routes/settings/apm_indices/get_apm_indices'; import { APMEventClient } from './lib/helpers/create_es_client/create_apm_event_client'; @@ -71,6 +72,7 @@ export interface APMPluginSetupDependencies { licensing: LicensingPluginSetup; observability: ObservabilityPluginSetup; ruleRegistry: RuleRegistryPluginSetupContract; + infra: InfraPluginSetup; // optional dependencies actions?: ActionsPlugin['setup']; @@ -92,6 +94,7 @@ export interface APMPluginStartDependencies { licensing: LicensingPluginStart; observability: undefined; ruleRegistry: RuleRegistryPluginStartContract; + infra: InfraPluginStart; // optional dependencies actions?: ActionsPlugin['start']; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index b69303aae21069..0213aa26d5ef3b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -21,6 +21,8 @@ import { SOURCES_PATH, PRIVATE_SOURCES_PATH, SOURCE_DETAILS_PATH, + getAddPath, + getEditPath, } from './routes'; const TestComponent = ({ id, isOrg }: { id: string; isOrg?: boolean }) => { @@ -86,3 +88,32 @@ describe('getReindexJobRoute', () => { ); }); }); + +describe('getAddPath', () => { + it('should handle a service type', () => { + expect(getAddPath('share_point')).toEqual('/sources/add/share_point'); + }); + + it('should should handle an external service type with no base service type', () => { + expect(getAddPath('external')).toEqual('/sources/add/external'); + }); + + it('should should handle an external service type with a base service type', () => { + expect(getAddPath('external', 'share_point')).toEqual('/sources/add/share_point/external'); + }); + it('should should handle a custom service type with no base service type', () => { + expect(getAddPath('external')).toEqual('/sources/add/external'); + }); + + it('should should handle a custom service type with a base service type', () => { + expect(getAddPath('custom', 'share_point_server')).toEqual( + '/sources/add/share_point_server/custom' + ); + }); +}); + +describe('getEditPath', () => { + it('should handle a service type', () => { + expect(getEditPath('share_point')).toEqual('/settings/connectors/share_point/edit'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index cbcd1d885b120e..fe1be10aa3b062 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -77,6 +77,14 @@ export const getReindexJobRoute = ( isOrganization: boolean ) => getSourcesPath(generatePath(REINDEX_JOB_PATH, { sourceId, activeReindexJobId }), isOrganization); -export const getAddPath = (serviceType: string): string => `${SOURCES_PATH}/add/${serviceType}`; + +export const getAddPath = (serviceType: string, baseServiceType?: string): string => { + const baseServiceTypePath = baseServiceType + ? `${baseServiceType}/${serviceType}` + : `${serviceType}`; + return `${SOURCES_PATH}/add/${baseServiceTypePath}`; +}; + +// TODO this should handle base service type once we are getting it back from registered external connectors export const getEditPath = (serviceType: string): string => `${ORG_SETTINGS_CONNECTORS_PATH}/${serviceType}/edit`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 984e6664681b47..32353230b36aaa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -72,18 +72,14 @@ export interface Configuration { export interface SourceDataItem { name: string; - iconName: string; categories?: string[]; serviceType: string; + baseServiceType?: string; configuration: Configuration; - configured?: boolean; connected?: boolean; features?: Features; objTypes?: string[]; accountContextOnly: boolean; - internalConnectorAvailable?: boolean; - externalConnectorAvailable?: boolean; - customConnectorAvailable?: boolean; isBeta?: boolean; } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts deleted file mode 100644 index fbfda1ddf8d5ef..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SourceDataItem } from '../types'; - -export const hasMultipleConnectorOptions = ({ - internalConnectorAvailable, - externalConnectorAvailable, - customConnectorAvailable, -}: SourceDataItem) => - [externalConnectorAvailable, internalConnectorAvailable, customConnectorAvailable].filter( - (available) => !!available - ).length > 1; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts index c66a6d1ca0fc0d..6f6af758c02832 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts @@ -11,6 +11,5 @@ export { mimeType } from './mime_types'; export { readUploadedFileAsBase64 } from './read_uploaded_file_as_base64'; export { readUploadedFileAsText } from './read_uploaded_file_as_text'; export { handlePrivateKeyUpload } from './handle_private_key_upload'; -export { hasMultipleConnectorOptions } from './has_multiple_connector_options'; export { isNotNullish } from './is_not_nullish'; export { sortByName } from './sort_by_name'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx index b606f9d7f56fda..9ff64dfe4f65b9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx @@ -7,6 +7,7 @@ import '../../../../../../__mocks__/shallow_useeffect.mock'; import { setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -17,7 +18,6 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../../components/layout'; -import { staticSourceData } from '../../../source_data'; import { AddCustomSource } from './add_custom_source'; import { AddCustomSourceSteps } from './add_custom_source_logic'; @@ -25,11 +25,6 @@ import { ConfigureCustom } from './configure_custom'; import { SaveCustom } from './save_custom'; describe('AddCustomSource', () => { - const props = { - sourceData: staticSourceData[0], - initialValues: undefined, - }; - const values = { sourceConfigData, isOrganization: true, @@ -37,17 +32,26 @@ describe('AddCustomSource', () => { beforeEach(() => { setMockValues({ ...values }); + mockUseParams.mockReturnValue({ baseServiceType: 'share_point_server' }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); }); + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ baseServiceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + it('should show correct layout for personal dashboard', () => { setMockValues({ isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); @@ -55,14 +59,14 @@ describe('AddCustomSource', () => { it('should show Configure Custom for custom configuration step', () => { setMockValues({ currentStep: AddCustomSourceSteps.ConfigureCustomStep }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ConfigureCustom)).toHaveLength(1); }); it('should show Save Custom for save custom step', () => { setMockValues({ currentStep: AddCustomSourceSteps.SaveCustomStep }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SaveCustom)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx index c2f6afba032c73..b15129665a7d41 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx @@ -7,6 +7,8 @@ import React from 'react'; +import { useParams } from 'react-router-dom'; + import { useValues } from 'kea'; import { AppLogic } from '../../../../../app_logic'; @@ -16,27 +18,38 @@ import { } from '../../../../../components/layout'; import { NAV } from '../../../../../constants'; -import { SourceDataItem } from '../../../../../types'; +import { getSourceData } from '../../../source_data'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; import { ConfigureCustom } from './configure_custom'; import { SaveCustom } from './save_custom'; -interface Props { - sourceData: SourceDataItem; - initialValue?: string; -} -export const AddCustomSource: React.FC = ({ sourceData, initialValue = '' }) => { - const addCustomSourceLogic = AddCustomSourceLogic({ sourceData, initialValue }); +export const AddCustomSource: React.FC = () => { + const { baseServiceType } = useParams<{ baseServiceType?: string }>(); + const sourceData = getSourceData('custom', baseServiceType); + + const addCustomSourceLogic = AddCustomSourceLogic({ + baseServiceType, + initialValue: sourceData?.name, + }); + const { currentStep } = useValues(addCustomSourceLogic); const { isOrganization } = useValues(AppLogic); + if (!sourceData) { + return null; + } + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - - {currentStep === AddCustomSourceSteps.ConfigureCustomStep && } - {currentStep === AddCustomSourceSteps.SaveCustomStep && } + + {currentStep === AddCustomSourceSteps.ConfigureCustomStep && ( + + )} + {currentStep === AddCustomSourceSteps.SaveCustomStep && ( + + )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts index d2187bd0b21a15..2ca3462da0f579 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts @@ -14,7 +14,6 @@ import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock' import { nextTick } from '@kbn/test-jest-helpers'; -import { docLinks } from '../../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers'; jest.mock('../../../../../app_logic', () => ({ @@ -22,35 +21,17 @@ jest.mock('../../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../../app_logic'; -import { SOURCE_NAMES } from '../../../../../constants'; -import { CustomSource, SourceDataItem } from '../../../../../types'; +import { CustomSource } from '../../../../../types'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; -const CUSTOM_SOURCE_DATA_ITEM: SourceDataItem = { - name: SOURCE_NAMES.CUSTOM, - iconName: SOURCE_NAMES.CUSTOM, - serviceType: 'custom', - configuration: { - isPublicKey: false, - hasOauthRedirect: false, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, - applicationPortalUrl: '', - }, - accountContextOnly: false, -}; - const DEFAULT_VALUES = { currentStep: AddCustomSourceSteps.ConfigureCustomStep, buttonLoading: false, customSourceNameValue: '', newCustomSource: {} as CustomSource, - sourceData: CUSTOM_SOURCE_DATA_ITEM, }; -const MOCK_PROPS = { initialValue: '', sourceData: CUSTOM_SOURCE_DATA_ITEM }; - const MOCK_NAME = 'name'; describe('AddCustomSourceLogic', () => { @@ -60,7 +41,7 @@ describe('AddCustomSourceLogic', () => { beforeEach(() => { jest.clearAllMocks(); - mount({}, MOCK_PROPS); + mount({}); }); it('has expected default values', () => { @@ -112,12 +93,9 @@ describe('AddCustomSourceLogic', () => { describe('listeners', () => { beforeEach(() => { - mount( - { - customSourceNameValue: MOCK_NAME, - }, - MOCK_PROPS - ); + mount({ + customSourceNameValue: MOCK_NAME, + }); }); describe('organization context', () => { @@ -151,11 +129,7 @@ describe('AddCustomSourceLogic', () => { customSourceNameValue: MOCK_NAME, }, { - ...MOCK_PROPS, - sourceData: { - ...CUSTOM_SOURCE_DATA_ITEM, - serviceType: 'sharepoint-server', - }, + baseServiceType: 'share_point_server', } ); @@ -165,7 +139,7 @@ describe('AddCustomSourceLogic', () => { body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME, - base_service_type: 'sharepoint-server', + base_service_type: 'share_point_server', }), }); }); @@ -199,11 +173,7 @@ describe('AddCustomSourceLogic', () => { customSourceNameValue: MOCK_NAME, }, { - ...MOCK_PROPS, - sourceData: { - ...CUSTOM_SOURCE_DATA_ITEM, - serviceType: 'sharepoint-server', - }, + baseServiceType: 'share_point_server', } ); @@ -215,7 +185,7 @@ describe('AddCustomSourceLogic', () => { body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME, - base_service_type: 'sharepoint-server', + base_service_type: 'share_point_server', }), } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts index f85e0761f51b5b..5b02fffa5892d6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts @@ -10,11 +10,11 @@ import { kea, MakeLogicType } from 'kea'; import { flashAPIErrors, clearFlashMessages } from '../../../../../../shared/flash_messages'; import { HttpLogic } from '../../../../../../shared/http'; import { AppLogic } from '../../../../../app_logic'; -import { CustomSource, SourceDataItem } from '../../../../../types'; +import { CustomSource } from '../../../../../types'; export interface AddCustomSourceProps { - sourceData: SourceDataItem; - initialValue: string; + baseServiceType?: string; + initialValue?: string; } export enum AddCustomSourceSteps { @@ -34,7 +34,6 @@ interface AddCustomSourceValues { currentStep: AddCustomSourceSteps; customSourceNameValue: string; newCustomSource: CustomSource; - sourceData: SourceDataItem; } /** @@ -67,7 +66,7 @@ export const AddCustomSourceLogic = kea< }, ], customSourceNameValue: [ - props.initialValue, + props.initialValue || '', { setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, }, @@ -78,7 +77,6 @@ export const AddCustomSourceLogic = kea< setNewCustomSource: (_, newCustomSource) => newCustomSource, }, ], - sourceData: [props.sourceData], }), listeners: ({ actions, values, props }) => ({ createContentSource: async () => { @@ -90,21 +88,12 @@ export const AddCustomSourceLogic = kea< const { customSourceNameValue } = values; - const baseParams = { + const params = { service_type: 'custom', name: customSourceNameValue, + base_service_type: props.baseServiceType, }; - // pre-configured custom sources have a serviceType reflecting their target service - // we submit this as `base_service_type` to keep track of - const params = - props.sourceData.serviceType === 'custom' - ? baseParams - : { - ...baseParams, - base_service_type: props.sourceData.serviceType, - }; - try { const response = await HttpLogic.values.http.post(route, { body: JSON.stringify(params), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx index 3ed60614d294a1..a0713ec530b28b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx @@ -21,24 +21,24 @@ import { ConfigureCustom } from './configure_custom'; describe('ConfigureCustom', () => { const setCustomSourceNameValue = jest.fn(); const createContentSource = jest.fn(); + const sourceData = staticSourceData[1]; beforeEach(() => { setMockActions({ setCustomSourceNameValue, createContentSource }); setMockValues({ customSourceNameValue: 'name', buttonLoading: false, - sourceData: staticSourceData[1], }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiForm)).toHaveLength(1); }); it('handles input change', () => { - const wrapper = shallow(); + const wrapper = shallow(); const text = 'changed for the better'; const input = wrapper.find(EuiFieldText); input.simulate('change', { target: { value: text } }); @@ -47,7 +47,7 @@ describe('ConfigureCustom', () => { }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('EuiForm').simulate('submit', { preventDefault }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx index 024dd698cc0a25..4f673f56231ccc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx @@ -21,11 +21,13 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; + import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../../shared/doc_links'; import connectionIllustration from '../../../../../assets/connection_illustration.svg'; +import { SourceDataItem } from '../../../../../types'; import { SOURCE_NAME_LABEL } from '../../../constants'; import { AddSourceHeader } from '../add_source_header'; @@ -33,9 +35,13 @@ import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT, CONFIG_INTRO_ALT_TEXT } import { AddCustomSourceLogic } from './add_custom_source_logic'; -export const ConfigureCustom: React.FC = () => { +interface ConfigureCustomProps { + sourceData: SourceDataItem; +} + +export const ConfigureCustom: React.FC = ({ sourceData }) => { const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic); - const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic); + const { customSourceNameValue, buttonLoading } = useValues(AddCustomSourceLogic); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx index 3de514a3e4d71d..8f4e6e7205ef2d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx @@ -25,18 +25,21 @@ const mockValues = { accessToken: 'token', name: 'name', }, - sourceData: staticCustomSourceData, }; +const sourceData = staticCustomSourceData; + describe('SaveCustom', () => { + beforeAll(() => { + jest.clearAllMocks(); + setMockValues(mockValues); + }); + describe('default behavior', () => { let wrapper: ShallowWrapper; beforeAll(() => { - jest.clearAllMocks(); - setMockValues(mockValues); - - wrapper = shallow(); + wrapper = shallow(); }); it('contains a button back to the sources list', () => { @@ -52,20 +55,14 @@ describe('SaveCustom', () => { let wrapper: ShallowWrapper; beforeAll(() => { - jest.clearAllMocks(); - setMockValues({ - ...mockValues, - sourceData: { - ...staticCustomSourceData, - serviceType: 'sharepoint-server', - configuration: { - ...staticCustomSourceData.configuration, - githubRepository: 'elastic/sharepoint-server-connector', - }, - }, - }); - - wrapper = shallow(); + wrapper = shallow( + + ); }); it('includes a link to provide feedback', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx index 9e5e3ac2782ee5..df62d2b2bdf160 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx @@ -21,12 +21,14 @@ import { EuiCallOut, EuiLink, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonTo } from '../../../../../../shared/react_router_helpers'; import { AppLogic } from '../../../../../app_logic'; import { SOURCES_PATH, getSourcesPath } from '../../../../../routes'; +import { SourceDataItem } from '../../../../../types'; import { CustomSourceDeployment } from '../../custom_source_deployment'; @@ -35,10 +37,14 @@ import { SAVE_CUSTOM_BODY1 as READY_TO_ACCEPT_REQUESTS_LABEL } from '../constant import { AddCustomSourceLogic } from './add_custom_source_logic'; -export const SaveCustom: React.FC = () => { - const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic); +interface SaveCustomProps { + sourceData: SourceDataItem; +} + +export const SaveCustom: React.FC = ({ sourceData }) => { + const { newCustomSource } = useValues(AddCustomSourceLogic); const { isOrganization } = useValues(AppLogic); - const { serviceType, name, categories = [] } = sourceData; + const { serviceType, baseServiceType, name, categories = [] } = sourceData; return ( <> @@ -92,10 +98,10 @@ export const SaveCustom: React.FC = () => { - + - {serviceType !== 'custom' && ( + {baseServiceType && ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx index 2d8b5192fd3b10..8f517b740b1525 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx @@ -7,6 +7,7 @@ import '../../../../../../__mocks__/shallow_useeffect.mock'; import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -19,24 +20,15 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../../components/layout'; -import { staticSourceData } from '../../../source_data'; import { ExternalConnectorConfig } from './external_connector_config'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; describe('ExternalConnectorConfig', () => { - const goBack = jest.fn(); - const onDeleteConfig = jest.fn(); const setExternalConnectorApiKey = jest.fn(); const setExternalConnectorUrl = jest.fn(); const saveExternalConnectorConfig = jest.fn(); - const props = { - sourceData: staticSourceData[0], - goBack, - onDeleteConfig, - }; - const values = { sourceConfigData, buttonLoading: false, @@ -48,37 +40,47 @@ describe('ExternalConnectorConfig', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockActions({ setExternalConnectorApiKey, setExternalConnectorUrl, saveExternalConnectorConfig, }); setMockValues({ ...values }); + mockUseParams.mockReturnValue({}); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ baseServiceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiSteps)).toHaveLength(1); expect(wrapper.find(EuiSteps).dive().find(ExternalConnectorFormFields)).toHaveLength(1); }); it('renders organizstion layout', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); }); it('should show correct layout for personal dashboard', () => { setMockValues({ ...values, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('form').simulate('submit', { preventDefault }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx index 5a2558f141ea05..0b4e34f47103b2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx @@ -7,11 +7,12 @@ import React, { FormEvent } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { EuiButton, - EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiForm, @@ -26,56 +27,41 @@ import { PersonalDashboardLayout, WorkplaceSearchPageTemplate, } from '../../../../../components/layout'; -import { NAV, REMOVE_BUTTON } from '../../../../../constants'; -import { SourceDataItem } from '../../../../../types'; - -import { staticExternalSourceData } from '../../../source_data'; +import { NAV } from '../../../../../constants'; +import { getSourceData } from '../../../source_data'; import { AddSourceHeader } from '../add_source_header'; import { ConfigDocsLinks } from '../config_docs_links'; -import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from '../constants'; +import { OAUTH_SAVE_CONFIG_BUTTON } from '../constants'; import { ExternalConnectorDocumentation } from './external_connector_documentation'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { ExternalConnectorLogic } from './external_connector_logic'; -interface SaveConfigProps { - sourceData: SourceDataItem; - goBack?: () => void; - onDeleteConfig?: () => void; -} - -export const ExternalConnectorConfig: React.FC = ({ - sourceData, - goBack, - onDeleteConfig, -}) => { - const serviceType = 'external'; +export const ExternalConnectorConfig: React.FC = () => { + const { baseServiceType } = useParams<{ baseServiceType?: string }>(); + const sourceData = getSourceData('external', baseServiceType); const { saveExternalConnectorConfig } = useActions(ExternalConnectorLogic); - const { - formDisabled, - buttonLoading, - externalConnectorUrl, - externalConnectorApiKey, - sourceConfigData, - urlValid, - } = useValues(ExternalConnectorLogic); + const { formDisabled, buttonLoading, externalConnectorUrl, externalConnectorApiKey, urlValid } = + useValues(ExternalConnectorLogic); const handleFormSubmission = (e: FormEvent) => { e.preventDefault(); saveExternalConnectorConfig({ url: externalConnectorUrl, apiKey: externalConnectorApiKey }); }; - const { name, categories } = sourceConfigData; - const { - configuration: { applicationLinkTitle, applicationPortalUrl }, - } = sourceData; const { isOrganization } = useValues(AppLogic); + if (!sourceData) { + return null; + } + const { - configuration: { documentationUrl }, - } = staticExternalSourceData; + name, + categories = [], + configuration: { applicationLinkTitle, applicationPortalUrl, documentationUrl }, + } = sourceData; const saveButton = ( @@ -83,22 +69,10 @@ export const ExternalConnectorConfig: React.FC = ({ ); - const deleteButton = ( - - {REMOVE_BUTTON} - - ); - - const backButton = {OAUTH_BACK_BUTTON}; - const formActions = ( {saveButton} - - {goBack && backButton} - {onDeleteConfig && deleteButton} - ); @@ -132,11 +106,17 @@ export const ExternalConnectorConfig: React.FC = ({ }, ]; - const header = ; + const header = ( + + ); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - + {header} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts index fb09695a3529d8..0603b59cc75b0e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts @@ -36,10 +36,6 @@ describe('ExternalConnectorLogic', () => { formDisabled: true, externalConnectorUrl: '', externalConnectorApiKey: '', - sourceConfigData: { - name: '', - categories: [], - }, urlValid: true, showInsecureUrlCallout: false, insecureUrl: true, @@ -52,7 +48,6 @@ describe('ExternalConnectorLogic', () => { formDisabled: false, insecureUrl: false, dataLoading: false, - sourceConfigData, }; beforeEach(() => { @@ -87,7 +82,6 @@ describe('ExternalConnectorLogic', () => { it('saves the source config', () => { expect(ExternalConnectorLogic.values).toEqual({ ...DEFAULT_VALUES_SUCCESS, - sourceConfigData, }); }); @@ -104,7 +98,6 @@ describe('ExternalConnectorLogic', () => { ...DEFAULT_VALUES_SUCCESS, externalConnectorUrl: '', insecureUrl: true, - sourceConfigData: newSourceConfigData, }); }); it('sets undefined api key to empty string', () => { @@ -119,7 +112,6 @@ describe('ExternalConnectorLogic', () => { expect(ExternalConnectorLogic.values).toEqual({ ...DEFAULT_VALUES_SUCCESS, externalConnectorApiKey: '', - sourceConfigData: newSourceConfigData, }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts index d1e4cf7f4f008a..e36b790edd8e98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts @@ -48,7 +48,6 @@ export interface ExternalConnectorValues { externalConnectorApiKey: string; externalConnectorUrl: string; urlValid: boolean; - sourceConfigData: SourceConfigData | Pick; insecureUrl: boolean; showInsecureUrlCallout: boolean; } @@ -107,12 +106,6 @@ export const ExternalConnectorLogic = kea< setShowInsecureUrlCallout: (_, showCallout) => showCallout, }, ], - sourceConfigData: [ - { name: '', categories: [] }, - { - fetchExternalSourceSuccess: (_, sourceConfigData) => sourceConfigData, - }, - ], urlValid: [ true, { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index a7cfa81d300212..8811a68e491811 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -11,6 +11,7 @@ import { setMockActions, setMockValues, } from '../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -22,13 +23,9 @@ import { PersonalDashboardLayout, } from '../../../../components/layout'; -import { staticSourceData } from '../../source_data'; - import { AddSource } from './add_source'; import { AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; -import { ConfigurationChoice } from './configuration_choice'; -import { ConfigurationIntro } from './configuration_intro'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; @@ -36,7 +33,7 @@ import { SaveConfig } from './save_config'; describe('AddSourceList', () => { const { navigateToUrl } = mockKibanaValues; - const initializeAddSource = jest.fn(); + const getSourceConfigData = jest.fn(); const setAddSourceStep = jest.fn(); const saveSourceConfig = jest.fn((_, setConfigCompletedStep) => { setConfigCompletedStep(); @@ -47,7 +44,7 @@ describe('AddSourceList', () => { const resetSourcesState = jest.fn(); const mockValues = { - addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, + addSourceCurrentStep: null, sourceConfigData, dataLoading: false, newCustomSource: {}, @@ -56,68 +53,29 @@ describe('AddSourceList', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockActions({ - initializeAddSource, + getSourceConfigData, setAddSourceStep, saveSourceConfig, createContentSource, resetSourcesState, }); setMockValues(mockValues); - }); - - it('renders default state', () => { - const wrapper = shallow(); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - expect(initializeAddSource).toHaveBeenCalled(); - }); - - it('renders default state correctly when there are multiple connector options', () => { - const wrapper = shallow( - - ); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ChoiceStep); - }); - - it('renders default state correctly when there are multiple connector options but external connector is configured', () => { - setMockValues({ ...mockValues, externalConfigured: true }); - const wrapper = shallow( - - ); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); + mockUseParams.mockReturnValue({ serviceType: 'confluence_cloud' }); }); describe('layout', () => { it('renders the default workplace search layout when on an organization view', () => { setMockValues({ ...mockValues, isOrganization: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); }); it('renders the personal dashboard layout when not in an organization', () => { setMockValues({ ...mockValues, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(PersonalDashboardLayout); }); @@ -125,7 +83,7 @@ describe('AddSourceList', () => { it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']); }); @@ -135,26 +93,24 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(false); wrapper.find(ConfigCompleted).prop('advanceStep')(); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); it('renders Config Completed step with feedback for external connectors', () => { + mockUseParams.mockReturnValue({ serviceType: 'external' }); setMockValues({ ...mockValues, sourceConfigData: { ...sourceConfigData, serviceType: 'external' }, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(true); + wrapper.find(ConfigCompleted).prop('advanceStep')(); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); @@ -163,13 +119,13 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.SaveConfigStep, }); - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); saveConfig.prop('advanceStep')(); - saveConfig.prop('goBackStep')!(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); expect(saveSourceConfig).toHaveBeenCalled(); + + saveConfig.prop('goBackStep')!(); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/intro'); }); it('renders Connect Instance step', () => { @@ -178,10 +134,11 @@ describe('AddSourceList', () => { sourceConfigData, addSourceCurrentStep: AddSourceSteps.ConnectInstanceStep, }); - const wrapper = shallow(); + + const wrapper = shallow(); wrapper.find(ConnectInstance).prop('onFormCreated')('foo'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources'); }); it('renders Configure Oauth step', () => { @@ -189,11 +146,11 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigureOauthStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigureOauth).prop('onFormCreated')('foo'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources'); }); it('renders Reauthenticate step', () => { @@ -201,23 +158,8 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ReauthenticateStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Reauthenticate)).toHaveLength(1); }); - - it('renders Config Choice step', () => { - setMockValues({ - ...mockValues, - addSourceCurrentStep: AddSourceSteps.ChoiceStep, - }); - const wrapper = shallow(); - const advance = wrapper.find(ConfigurationChoice).prop('goToInternalStep'); - expect(advance).toBeDefined(); - if (advance) { - advance(); - } - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index 4bdf8db217a7b3..5b992703def61f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -7,29 +7,28 @@ import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { i18n } from '@kbn/i18n'; import { flashSuccessToast } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; import { AppLogic } from '../../../../app_logic'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../components/layout'; import { NAV } from '../../../../constants'; -import { SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; - -import { hasMultipleConnectorOptions } from '../../../../utils'; +import { SOURCES_PATH, getSourcesPath, getAddPath, ADD_SOURCE_PATH } from '../../../../routes'; -import { SourcesLogic } from '../../sources_logic'; +import { getSourceData } from '../../source_data'; import { AddSourceHeader } from './add_source_header'; -import { AddSourceLogic, AddSourceProps, AddSourceSteps } from './add_source_logic'; +import { AddSourceLogic, AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; -import { ConfigurationChoice } from './configuration_choice'; -import { ConfigurationIntro } from './configuration_intro'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; @@ -37,27 +36,42 @@ import { SaveConfig } from './save_config'; import './add_source.scss'; -export const AddSource: React.FC = (props) => { - const { initializeAddSource, setAddSourceStep, saveSourceConfig, resetSourceState } = - useActions(AddSourceLogic); - const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(AddSourceLogic); - const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = - sourceConfigData; - const { serviceType, configuration, features, objTypes } = props.sourceData; - const addPath = getAddPath(serviceType); +export const AddSource: React.FC = () => { + const { serviceType, initialStep } = useParams<{ serviceType: string; initialStep?: string }>(); + const addSourceLogic = AddSourceLogic({ serviceType, initialStep }); + const { getSourceConfigData, setAddSourceStep, saveSourceConfig, resetSourceState } = + useActions(addSourceLogic); + const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(addSourceLogic); const { isOrganization } = useValues(AppLogic); - const { externalConfigured } = useValues(SourcesLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); useEffect(() => { - initializeAddSource(props); + getSourceConfigData(); return resetSourceState; - }, []); + }, [serviceType]); + + const sourceData = getSourceData(serviceType); + + if (!sourceData) { + return null; + } + + const { configuration, features, objTypes } = sourceData; + + const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = + sourceConfigData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } - const goToConfigurationIntro = () => setAddSourceStep(AddSourceSteps.ConfigIntroStep); - const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); + const goToConfigurationIntro = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/intro` + ); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); const goToConfigCompleted = () => saveSourceConfig(false, setConfigCompletedStep); - const goToChoice = () => setAddSourceStep(AddSourceSteps.ChoiceStep); const FORM_SOURCE_ADDED_SUCCESS_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.formSourceAddedSuccessMessage', { @@ -66,11 +80,7 @@ export const AddSource: React.FC = (props) => { } ); - const goToConnectInstance = () => { - setAddSourceStep(AddSourceSteps.ConnectInstanceStep); - KibanaLogic.values.navigateToUrl(`${getSourcesPath(addPath, isOrganization)}/connect`); - }; - + const goToConnectInstance = () => setAddSourceStep(AddSourceSteps.ConnectInstanceStep); const goToFormSourceCreated = () => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); flashSuccessToast(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); @@ -81,18 +91,6 @@ export const AddSource: React.FC = (props) => { return ( - {addSourceCurrentStep === AddSourceSteps.ConfigIntroStep && ( - - )} {addSourceCurrentStep === AddSourceSteps.SaveConfigStep && ( = (props) => { {addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && ( )} - {addSourceCurrentStep === AddSourceSteps.ChoiceStep && ( - - )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx new file mode 100644 index 00000000000000..75b45da2b38b10 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues, mockKibanaValues } from '../../../../../__mocks__/kea_logic'; + +import { mockUseParams } from '../../../../../__mocks__/react_router'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; + +import { getSourceData } from '../../source_data'; + +import { AddSourceChoice } from './add_source_choice'; +import { ConfigurationChoice } from './configuration_choice'; + +describe('AddSourceChoice', () => { + const { navigateToUrl } = mockKibanaValues; + + const mockValues = { + isOrganization: true, + hasPlatinumLicense: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('redirects to root add source path if user does not have a platinum license and the service is account context only', () => { + mockUseParams.mockReturnValue({ serviceType: 'slack' }); + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + + shallow(); + + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add'); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); + + it('renders Config Choice step', () => { + setMockValues(mockValues); + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationChoice).prop('sourceData')).toEqual( + getSourceData('share_point') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx new file mode 100644 index 00000000000000..1034d207c99078 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useParams } from 'react-router-dom'; + +import { useValues } from 'kea'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; + +import { getSourcesPath, ADD_SOURCE_PATH } from '../../../../routes'; + +import { getSourceData } from '../../source_data'; + +import { ConfigurationChoice } from './configuration_choice'; + +import './add_source.scss'; + +export const AddSourceChoice: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); + const sourceData = getSourceData(serviceType); + + const { isOrganization } = useValues(AppLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); + + if (!sourceData) { + return null; + } + + const { name, accountContextOnly } = sourceData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx new file mode 100644 index 00000000000000..a7eeadf3a615e3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues } from '../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../__mocks__/react_router'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; + +import { AddSourceIntro } from './add_source_intro'; +import { ConfigurationIntro } from './configuration_intro'; + +describe('AddSourceList', () => { + const mockValues = { + isOrganization: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('sends the user to a choice view when there are multiple connector options', () => { + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationIntro).prop('advanceStepTo')).toEqual( + '/sources/add/share_point/choice' + ); + }); + + it('sends the user to the add source view by default', () => { + mockUseParams.mockReturnValue({ serviceType: 'slack' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationIntro).prop('advanceStepTo')).toEqual('/sources/add/slack/'); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx new file mode 100644 index 00000000000000..b375f04a27f0ff --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useParams } from 'react-router-dom'; + +import { useValues } from 'kea'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; +import { getSourcesPath, ADD_SOURCE_PATH, getAddPath } from '../../../../routes'; + +import { getSourceData, hasMultipleConnectorOptions } from '../../source_data'; + +import { AddSourceHeader } from './add_source_header'; +import { ConfigurationIntro } from './configuration_intro'; + +import './add_source.scss'; + +export const AddSourceIntro: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); + const sourceData = getSourceData(serviceType); + + const { isOrganization } = useValues(AppLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); + + if (!sourceData) { + return null; + } + + const { name, categories = [], accountContextOnly } = sourceData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } + + const header = ; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + const to = + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/` + + (hasMultipleConnectorOptions(serviceType) ? 'choice' : ''); + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 88ca96b8c0fbf0..3224628e72c73e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -15,7 +15,6 @@ import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; -import { docLinks } from '../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; jest.mock('../../../../app_logic', () => ({ @@ -23,10 +22,9 @@ jest.mock('../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../app_logic'; -import { SOURCE_NAMES, SOURCE_OBJ_TYPES } from '../../../../constants'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../../../routes'; -import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; +import { staticSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic'; @@ -37,7 +35,6 @@ import { SourceConnectData, OrganizationsMap, AddSourceValues, - AddSourceProps, } from './add_source_logic'; describe('AddSourceLogic', () => { @@ -47,8 +44,7 @@ describe('AddSourceLogic', () => { const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; const DEFAULT_VALUES: AddSourceValues = { - addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, - addSourceProps: {} as AddSourceProps, + addSourceCurrentStep: null, dataLoading: true, sectionLoading: true, buttonLoading: false, @@ -62,11 +58,11 @@ describe('AddSourceLogic', () => { sourceConfigData: {} as SourceConfigData, sourceConnectData: {} as SourceConnectData, oauthConfigCompleted: false, - currentServiceType: '', githubOrganizations: [], selectedGithubOrganizationsMap: {} as OrganizationsMap, selectedGithubOrganizations: [], preContentSourceId: '', + sourceData: staticSourceData[0], }; const sourceConnectData = { @@ -79,40 +75,13 @@ describe('AddSourceLogic', () => { serviceType: 'github', githubOrganizations: ['foo', 'bar'], }; - const DEFAULT_SERVICE_TYPE = { - name: SOURCE_NAMES.BOX, - iconName: SOURCE_NAMES.BOX, - serviceType: 'box', - configuration: { - isPublicKey: false, - hasOauthRedirect: true, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchBox, - applicationPortalUrl: 'https://app.box.com/developers/console', - }, - objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], - features: { - basicOrgContext: [ - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - FeatureIds.GlobalAccessPermissions, - ], - basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], - platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], - platinumPrivateContext: [ - FeatureIds.Private, - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - ], - }, - accountContextOnly: false, - }; + const DEFAULT_SERVICE_TYPE = 'box'; beforeEach(() => { jest.clearAllMocks(); ExternalConnectorLogic.mount(); SourcesLogic.mount(); - mount(); + mount({}, { serviceType: 'box' }); }); it('has expected default values', () => { @@ -215,7 +184,6 @@ describe('AddSourceLogic', () => { oauthConfigCompleted: true, dataLoading: false, sectionLoading: false, - currentServiceType: config.serviceType, githubOrganizations: config.githubOrganizations, }); }); @@ -286,140 +254,90 @@ describe('AddSourceLogic', () => { }); describe('listeners', () => { - it('initializeAddSource', () => { - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; - const getSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'getSourceConfigData'); - const setAddSourcePropsSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceProps'); - - AddSourceLogic.actions.initializeAddSource(addSourceProps); - - expect(setAddSourcePropsSpy).toHaveBeenCalledWith({ addSourceProps }); - expect(getSourceConfigDataSpy).toHaveBeenCalledWith('box', addSourceProps); - }); - describe('setFirstStep', () => { - it('sets intro as first step', () => { + it('sets save config as first step if unconfigured', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, + configured: false, + }, + }, + { serviceType: DEFAULT_SERVICE_TYPE } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; - AddSourceLogic.actions.setFirstStep(addSourceProps); - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); + AddSourceLogic.actions.setFirstStep(); + + expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); }); + it('sets connect as first step', () => { + mount({ sourceConfigData }, { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'connect' }); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, connect: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); it('sets configure as first step', () => { + mount( + { sourceConfigData }, + { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'configure' } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, configure: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureOauthStep); }); - it('sets reAuthenticate as first step', () => { + it('sets reauthenticate as first step', () => { + mount( + { sourceConfigData }, + { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'reauthenticate' } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, reAuthenticate: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ReauthenticateStep); }); - it('sets SaveConfig as first step for external connectors', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - serviceType: 'external', - }, - }; - AddSourceLogic.actions.setFirstStep(addSourceProps); - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); - it('sets SaveConfigStep for when external connector is available and configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - externalConnectorAvailable: true, - }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - serviceType: 'external', - configured: false, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { - serviceType: 'external', + it('sets connect step if configured', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, configured: true, }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); - - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); - it('sets Connect step when configured and external connector is available and configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - externalConnectorAvailable: true, - configured: true, }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - serviceType: 'external', - configured: true, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { - serviceType: 'external', - configured: true, - }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); + { serviceType: DEFAULT_SERVICE_TYPE } + ); + const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); - it('sets Connect step when external and fully configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - serviceType: 'external', - }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - configured: true, - serviceType: 'external', - configuredFields: { clientId: 'a', clientSecret: 'b' }, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { + + it('sets connect step if external connector has client id and secret', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, serviceType: 'external', - configured: true, + configuredFields: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); + }, + { serviceType: DEFAULT_SERVICE_TYPE } + ); + const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); @@ -541,30 +459,33 @@ describe('AddSourceLogic', () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); http.get.mockReturnValue(Promise.resolve(sourceConfigData)); - AddSourceLogic.actions.getSourceConfigData('github'); + AddSourceLogic.actions.getSourceConfigData(); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/settings/connectors/github' + '/internal/workplace_search/org/settings/connectors/box' ); - await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); }); + it('calls API and sets values and calls setFirstStep if AddSourceProps is provided', async () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); const setFirstStepSpy = jest.spyOn(AddSourceLogic.actions, 'setFirstStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; + http.get.mockReturnValue(Promise.resolve(sourceConfigData)); - AddSourceLogic.actions.getSourceConfigData('github', addSourceProps); + AddSourceLogic.actions.getSourceConfigData(); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/settings/connectors/github' + '/internal/workplace_search/org/settings/connectors/box' ); - await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); - expect(setFirstStepSpy).toHaveBeenCalledWith(addSourceProps); + expect(setFirstStepSpy).toHaveBeenCalled(); }); itShowsServerErrorAsFlashMessage(http.get, () => { - AddSourceLogic.actions.getSourceConfigData('github'); + AddSourceLogic.actions.getSourceConfigData(); }); }); @@ -579,7 +500,7 @@ describe('AddSourceLogic', () => { ); http.get.mockReturnValue(Promise.resolve(sourceConnectData)); - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); const query = { index_permissions: false, @@ -588,7 +509,7 @@ describe('AddSourceLogic', () => { expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/prepare', + '/internal/workplace_search/org/sources/box/prepare', { query, } @@ -602,7 +523,7 @@ describe('AddSourceLogic', () => { it('passes query params', () => { AddSourceLogic.actions.setSourceSubdomainValue('subdomain'); AddSourceLogic.actions.setSourceIndexPermissionsValue(true); - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); const query = { index_permissions: true, @@ -610,7 +531,7 @@ describe('AddSourceLogic', () => { }; expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/prepare', + '/internal/workplace_search/org/sources/box/prepare', { query, } @@ -618,7 +539,7 @@ describe('AddSourceLogic', () => { }); itShowsServerErrorAsFlashMessage(http.get, () => { - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); }); }); @@ -833,7 +754,7 @@ describe('AddSourceLogic', () => { const successCallback = jest.fn(); const errorCallback = jest.fn(); - const serviceType = 'zendesk'; + const serviceType = 'box'; const login = 'login'; const password = 'password'; const indexPermissions = false; @@ -859,7 +780,7 @@ describe('AddSourceLogic', () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); http.post.mockReturnValue(Promise.resolve()); - AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); + AddSourceLogic.actions.createContentSource(successCallback, errorCallback); expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); @@ -875,7 +796,7 @@ describe('AddSourceLogic', () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); http.post.mockReturnValue(Promise.reject('this is an error')); - AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); + AddSourceLogic.actions.createContentSource(successCallback, errorCallback); await nextTick(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -891,10 +812,10 @@ describe('AddSourceLogic', () => { }); it('getSourceConnectData', () => { - AddSourceLogic.actions.getSourceConnectData('github', jest.fn()); + AddSourceLogic.actions.getSourceConnectData(jest.fn()); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/account/sources/github/prepare', + '/internal/workplace_search/account/sources/box/prepare', { query: {} } ); }); @@ -915,10 +836,10 @@ describe('AddSourceLogic', () => { }); it('createContentSource', () => { - AddSourceLogic.actions.createContentSource('github', jest.fn()); + AddSourceLogic.actions.createContentSource(jest.fn()); expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/account/create_source', { - body: JSON.stringify({ service_type: 'github' }), + body: JSON.stringify({ service_type: 'box' }), }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 97a58966ad76ab..a087f1b78571b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -23,6 +23,7 @@ import { AppLogic } from '../../../../app_logic'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; +import { getSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; import { @@ -31,20 +32,16 @@ import { } from './add_external_connector/external_connector_logic'; export interface AddSourceProps { - sourceData: SourceDataItem; - connect?: boolean; - configure?: boolean; - reAuthenticate?: boolean; + serviceType: string; + initialStep?: string; } export enum AddSourceSteps { - ConfigIntroStep = 'Config Intro', SaveConfigStep = 'Save Config', ConfigCompletedStep = 'Config Completed', ConnectInstanceStep = 'Connect Instance', ConfigureOauthStep = 'Configure Oauth', ReauthenticateStep = 'Reauthenticate', - ChoiceStep = 'Choice', } export interface OauthParams { @@ -57,10 +54,6 @@ export interface OauthParams { } export interface AddSourceActions { - initializeAddSource: (addSourceProps: AddSourceProps) => { addSourceProps: AddSourceProps }; - setAddSourceProps: ({ addSourceProps }: { addSourceProps: AddSourceProps }) => { - addSourceProps: AddSourceProps; - }; setAddSourceStep(addSourceCurrentStep: AddSourceSteps): AddSourceSteps; setSourceConfigData(sourceConfigData: SourceConfigData): SourceConfigData; setSourceConnectData(sourceConnectData: SourceConnectData): SourceConnectData; @@ -76,10 +69,9 @@ export interface AddSourceActions { setSelectedGithubOrganizations(option: string): string; resetSourceState(): void; createContentSource( - serviceType: string, successCallback: () => void, errorCallback?: () => void - ): { serviceType: string; successCallback(): void; errorCallback?(): void }; + ): { successCallback(): void; errorCallback?(): void }; saveSourceConfig( isUpdating: boolean, successCallback?: () => void @@ -89,24 +81,22 @@ export interface AddSourceActions { params: OauthParams, isOrganization: boolean ): { search: Search; params: OauthParams; isOrganization: boolean }; - getSourceConfigData( - serviceType: string, - addSourceProps?: AddSourceProps - ): { serviceType: string; addSourceProps: AddSourceProps | undefined }; - getSourceConnectData( - serviceType: string, - successCallback: (oauthUrl: string) => void - ): { serviceType: string; successCallback(oauthUrl: string): void }; + getSourceConfigData(): void; + getSourceConnectData(successCallback: (oauthUrl: string) => void): { + successCallback(oauthUrl: string): void; + }; getSourceReConnectData(sourceId: string): { sourceId: string }; getPreContentSourceConfigData(): void; setButtonNotLoading(): void; - setFirstStep(addSourceProps: AddSourceProps): { addSourceProps: AddSourceProps }; + setFirstStep(): void; } export interface SourceConfigData { serviceType: string; + baseServiceType?: string; name: string; configured: boolean; + externalConnectorServiceDescribed?: boolean; categories: string[]; needsPermissions?: boolean; privateSourcesEnabled: boolean; @@ -133,8 +123,7 @@ export interface OrganizationsMap { } export interface AddSourceValues { - addSourceProps: AddSourceProps; - addSourceCurrentStep: AddSourceSteps; + addSourceCurrentStep: AddSourceSteps | null; dataLoading: boolean; sectionLoading: boolean; buttonLoading: boolean; @@ -147,12 +136,12 @@ export interface AddSourceValues { indexPermissionsValue: boolean; sourceConfigData: SourceConfigData; sourceConnectData: SourceConnectData; - currentServiceType: string; githubOrganizations: string[]; selectedGithubOrganizationsMap: OrganizationsMap; selectedGithubOrganizations: string[]; preContentSourceId: string; oauthConfigCompleted: boolean; + sourceData: SourceDataItem | null; } interface PreContentSourceResponse { @@ -161,471 +150,436 @@ interface PreContentSourceResponse { githubOrganizations: string[]; } -export const AddSourceLogic = kea>({ - path: ['enterprise_search', 'workplace_search', 'add_source_logic'], - actions: { - initializeAddSource: (addSourceProps: AddSourceProps) => ({ addSourceProps }), - setAddSourceProps: ({ addSourceProps }: { addSourceProps: AddSourceProps }) => ({ - addSourceProps, - }), - setAddSourceStep: (addSourceCurrentStep: AddSourceSteps) => addSourceCurrentStep, - setSourceConfigData: (sourceConfigData: SourceConfigData) => sourceConfigData, - setSourceConnectData: (sourceConnectData: SourceConnectData) => sourceConnectData, - setClientIdValue: (clientIdValue: string) => clientIdValue, - setClientSecretValue: (clientSecretValue: string) => clientSecretValue, - setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, - setSourceLoginValue: (loginValue: string) => loginValue, - setSourcePasswordValue: (passwordValue: string) => passwordValue, - setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, - setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, - setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, - setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, - setSelectedGithubOrganizations: (option: string) => option, - getSourceConfigData: (serviceType: string, addSourceProps?: AddSourceProps) => ({ - serviceType, - addSourceProps, - }), - getSourceConnectData: (serviceType: string, successCallback: (oauthUrl: string) => string) => ({ - serviceType, - successCallback, - }), - getSourceReConnectData: (sourceId: string) => ({ sourceId }), - getPreContentSourceConfigData: () => true, - saveSourceConfig: (isUpdating: boolean, successCallback?: () => void) => ({ - isUpdating, - successCallback, +export const AddSourceLogic = kea>( + { + path: ['enterprise_search', 'workplace_search', 'add_source_logic'], + actions: { + setAddSourceStep: (addSourceCurrentStep: AddSourceSteps) => addSourceCurrentStep, + setSourceConfigData: (sourceConfigData: SourceConfigData) => sourceConfigData, + setSourceConnectData: (sourceConnectData: SourceConnectData) => sourceConnectData, + setClientIdValue: (clientIdValue: string) => clientIdValue, + setClientSecretValue: (clientSecretValue: string) => clientSecretValue, + setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, + setSourceLoginValue: (loginValue: string) => loginValue, + setSourcePasswordValue: (passwordValue: string) => passwordValue, + setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, + setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, + setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, + setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, + setSelectedGithubOrganizations: (option: string) => option, + getSourceConfigData: () => true, + getSourceConnectData: (successCallback: (oauthUrl: string) => string) => ({ + successCallback, + }), + getSourceReConnectData: (sourceId: string) => ({ sourceId }), + getPreContentSourceConfigData: () => true, + saveSourceConfig: (isUpdating: boolean, successCallback?: () => void) => ({ + isUpdating, + successCallback, + }), + saveSourceParams: (search: Search, params: OauthParams, isOrganization: boolean) => ({ + search, + params, + isOrganization, + }), + createContentSource: (successCallback: () => void, errorCallback?: () => void) => ({ + successCallback, + errorCallback, + }), + resetSourceState: () => true, + setButtonNotLoading: () => true, + setFirstStep: () => true, + }, + reducers: ({ props }) => ({ + addSourceCurrentStep: [ + null, + { + setAddSourceStep: (_, addSourceCurrentStep) => addSourceCurrentStep, + }, + ], + sourceConfigData: [ + {} as SourceConfigData, + { + setSourceConfigData: (_, sourceConfigData) => sourceConfigData, + }, + ], + sourceConnectData: [ + {} as SourceConnectData, + { + setSourceConnectData: (_, sourceConnectData) => sourceConnectData, + }, + ], + dataLoading: [ + true, + { + setSourceConfigData: () => false, + resetSourceState: () => false, + setPreContentSourceConfigData: () => false, + getSourceConfigData: () => true, + }, + ], + buttonLoading: [ + false, + { + setButtonNotLoading: () => false, + setSourceConnectData: () => false, + setSourceConfigData: () => false, + resetSourceState: () => false, + saveSourceConfig: () => true, + getSourceConnectData: () => true, + createContentSource: () => true, + }, + ], + sectionLoading: [ + true, + { + getPreContentSourceConfigData: () => true, + setPreContentSourceConfigData: () => false, + }, + ], + clientIdValue: [ + '', + { + setClientIdValue: (_, clientIdValue) => clientIdValue, + setSourceConfigData: (_, { configuredFields: { clientId } }) => clientId || '', + resetSourceState: () => '', + }, + ], + clientSecretValue: [ + '', + { + setClientSecretValue: (_, clientSecretValue) => clientSecretValue, + setSourceConfigData: (_, { configuredFields: { clientSecret } }) => clientSecret || '', + resetSourceState: () => '', + }, + ], + baseUrlValue: [ + '', + { + setBaseUrlValue: (_, baseUrlValue) => baseUrlValue, + setSourceConfigData: (_, { configuredFields: { baseUrl } }) => baseUrl || '', + resetSourceState: () => '', + }, + ], + loginValue: [ + '', + { + setSourceLoginValue: (_, loginValue) => loginValue, + resetSourceState: () => '', + }, + ], + passwordValue: [ + '', + { + setSourcePasswordValue: (_, passwordValue) => passwordValue, + resetSourceState: () => '', + }, + ], + subdomainValue: [ + '', + { + setSourceSubdomainValue: (_, subdomainValue) => subdomainValue, + resetSourceState: () => '', + }, + ], + indexPermissionsValue: [ + false, + { + setSourceIndexPermissionsValue: (_, indexPermissionsValue) => indexPermissionsValue, + resetSourceState: () => false, + }, + ], + githubOrganizations: [ + [], + { + setPreContentSourceConfigData: (_, { githubOrganizations }) => githubOrganizations, + resetSourceState: () => [], + }, + ], + selectedGithubOrganizationsMap: [ + {} as OrganizationsMap, + { + setSelectedGithubOrganizations: (state, option) => ({ + ...state, + ...{ [option]: !state[option] }, + }), + resetSourceState: () => ({}), + }, + ], + preContentSourceId: [ + '', + { + setPreContentSourceId: (_, preContentSourceId) => preContentSourceId, + setPreContentSourceConfigData: () => '', + resetSourceState: () => '', + }, + ], + oauthConfigCompleted: [ + false, + { + setPreContentSourceConfigData: () => true, + }, + ], + sourceData: [getSourceData(props.serviceType) || null, {}], }), - saveSourceParams: (search: Search, params: OauthParams, isOrganization: boolean) => ({ - search, - params, - isOrganization, + selectors: ({ selectors }) => ({ + selectedGithubOrganizations: [ + () => [selectors.selectedGithubOrganizationsMap], + (orgsMap) => keys(pickBy(orgsMap)), + ], }), - createContentSource: ( - serviceType: string, - successCallback: () => void, - errorCallback?: () => void - ) => ({ serviceType, successCallback, errorCallback }), - resetSourceState: () => true, - setButtonNotLoading: () => false, - setFirstStep: (addSourceProps) => ({ addSourceProps }), - }, - reducers: { - addSourceProps: [ - {} as AddSourceProps, - { - setAddSourceProps: (_, { addSourceProps }) => addSourceProps, - }, - ], - addSourceCurrentStep: [ - AddSourceSteps.ConfigIntroStep, - { - setAddSourceStep: (_, addSourceCurrentStep) => addSourceCurrentStep, - }, - ], - sourceConfigData: [ - {} as SourceConfigData, - { - setSourceConfigData: (_, sourceConfigData) => sourceConfigData, - }, - ], - sourceConnectData: [ - {} as SourceConnectData, - { - setSourceConnectData: (_, sourceConnectData) => sourceConnectData, - }, - ], - dataLoading: [ - true, - { - setSourceConfigData: () => false, - resetSourceState: () => false, - setPreContentSourceConfigData: () => false, - getSourceConfigData: () => true, - }, - ], - buttonLoading: [ - false, - { - setButtonNotLoading: () => false, - setSourceConnectData: () => false, - setSourceConfigData: () => false, - resetSourceState: () => false, - saveSourceConfig: () => true, - getSourceConnectData: () => true, - createContentSource: () => true, - }, - ], - sectionLoading: [ - true, - { - getPreContentSourceConfigData: () => true, - setPreContentSourceConfigData: () => false, - }, - ], - clientIdValue: [ - '', - { - setClientIdValue: (_, clientIdValue) => clientIdValue, - setSourceConfigData: (_, { configuredFields: { clientId } }) => clientId || '', - resetSourceState: () => '', - }, - ], - clientSecretValue: [ - '', - { - setClientSecretValue: (_, clientSecretValue) => clientSecretValue, - setSourceConfigData: (_, { configuredFields: { clientSecret } }) => clientSecret || '', - resetSourceState: () => '', - }, - ], - baseUrlValue: [ - '', - { - setBaseUrlValue: (_, baseUrlValue) => baseUrlValue, - setSourceConfigData: (_, { configuredFields: { baseUrl } }) => baseUrl || '', - resetSourceState: () => '', - }, - ], - loginValue: [ - '', - { - setSourceLoginValue: (_, loginValue) => loginValue, - resetSourceState: () => '', - }, - ], - passwordValue: [ - '', - { - setSourcePasswordValue: (_, passwordValue) => passwordValue, - resetSourceState: () => '', - }, - ], - subdomainValue: [ - '', - { - setSourceSubdomainValue: (_, subdomainValue) => subdomainValue, - resetSourceState: () => '', - }, - ], - indexPermissionsValue: [ - false, - { - setSourceIndexPermissionsValue: (_, indexPermissionsValue) => indexPermissionsValue, - resetSourceState: () => false, - }, - ], - currentServiceType: [ - '', - { - setPreContentSourceConfigData: (_, { serviceType }) => serviceType, - resetSourceState: () => '', - }, - ], - githubOrganizations: [ - [], - { - setPreContentSourceConfigData: (_, { githubOrganizations }) => githubOrganizations, - resetSourceState: () => [], + listeners: ({ actions, values, props }) => ({ + getSourceConfigData: async () => { + const { serviceType } = props; + // TODO: Once multi-config support for connectors is added, this request url will need to include an ID + const route = `/internal/workplace_search/org/settings/connectors/${serviceType}`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setSourceConfigData(response); + actions.setFirstStep(); + } catch (e) { + flashAPIErrors(e); + } }, - ], - selectedGithubOrganizationsMap: [ - {} as OrganizationsMap, - { - setSelectedGithubOrganizations: (state, option) => ({ - ...state, - ...{ [option]: !state[option] }, - }), - resetSourceState: () => ({}), + getSourceConnectData: async ({ successCallback }) => { + const { serviceType } = props; + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const { subdomainValue: subdomain, indexPermissionsValue: indexPermissions } = values; + + const route = isOrganization + ? `/internal/workplace_search/org/sources/${serviceType}/prepare` + : `/internal/workplace_search/account/sources/${serviceType}/prepare`; + + const indexPermissionsQuery = isOrganization + ? { index_permissions: indexPermissions } + : undefined; + + const query = subdomain + ? { + ...indexPermissionsQuery, + subdomain, + } + : { ...indexPermissionsQuery }; + + try { + const response = await HttpLogic.values.http.get(route, { + query, + }); + actions.setSourceConnectData(response); + successCallback(response.oauthUrl); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); + } }, - ], - preContentSourceId: [ - '', - { - setPreContentSourceId: (_, preContentSourceId) => preContentSourceId, - setPreContentSourceConfigData: () => '', - resetSourceState: () => '', + getSourceReConnectData: async ({ sourceId }) => { + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? `/internal/workplace_search/org/sources/${sourceId}/reauth_prepare` + : `/internal/workplace_search/account/sources/${sourceId}/reauth_prepare`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setSourceConnectData(response); + } catch (e) { + flashAPIErrors(e); + } }, - ], - oauthConfigCompleted: [ - false, - { - setPreContentSourceConfigData: () => true, + getPreContentSourceConfigData: async () => { + const { isOrganization } = AppLogic.values; + const { preContentSourceId } = values; + const route = isOrganization + ? `/internal/workplace_search/org/pre_sources/${preContentSourceId}` + : `/internal/workplace_search/account/pre_sources/${preContentSourceId}`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setPreContentSourceConfigData(response); + } catch (e) { + flashAPIErrors(e); + } }, - ], - }, - selectors: ({ selectors }) => ({ - selectedGithubOrganizations: [ - () => [selectors.selectedGithubOrganizationsMap], - (orgsMap) => keys(pickBy(orgsMap)), - ], - }), - listeners: ({ actions, values }) => ({ - initializeAddSource: ({ addSourceProps }) => { - const { serviceType } = addSourceProps.sourceData; - actions.setAddSourceProps({ addSourceProps }); - actions.getSourceConfigData(serviceType, addSourceProps); - }, - getSourceConfigData: async ({ serviceType, addSourceProps }) => { - const route = `/internal/workplace_search/org/settings/connectors/${serviceType}`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setSourceConfigData(response); - if (addSourceProps) { - actions.setFirstStep(addSourceProps); + saveSourceConfig: async ({ isUpdating, successCallback }) => { + clearFlashMessages(); + const { + sourceConfigData: { serviceType }, + baseUrlValue, + clientIdValue, + clientSecretValue, + sourceConfigData, + } = values; + + const { externalConnectorUrl, externalConnectorApiKey } = ExternalConnectorLogic.values; + if ( + serviceType === 'external' && + externalConnectorUrl && + !isValidExternalUrl(externalConnectorUrl) + ) { + ExternalConnectorLogic.actions.setUrlValidation(false); + actions.setButtonNotLoading(); + return; } - } catch (e) { - flashAPIErrors(e); - } - }, - getSourceConnectData: async ({ serviceType, successCallback }) => { - clearFlashMessages(); - const { isOrganization } = AppLogic.values; - const { subdomainValue: subdomain, indexPermissionsValue: indexPermissions } = values; - - const route = isOrganization - ? `/internal/workplace_search/org/sources/${serviceType}/prepare` - : `/internal/workplace_search/account/sources/${serviceType}/prepare`; - - const indexPermissionsQuery = isOrganization - ? { index_permissions: indexPermissions } - : undefined; - - const query = subdomain - ? { - ...indexPermissionsQuery, - subdomain, + + const route = isUpdating + ? `/internal/workplace_search/org/settings/connectors/${serviceType}` + : '/internal/workplace_search/org/settings/connectors'; + + const http = isUpdating ? HttpLogic.values.http.put : HttpLogic.values.http.post; + + const params = { + base_url: baseUrlValue || undefined, + client_id: clientIdValue || undefined, + client_secret: clientSecretValue || undefined, + service_type: serviceType, + private_key: sourceConfigData.configuredFields?.privateKey, + public_key: sourceConfigData.configuredFields?.publicKey, + consumer_key: sourceConfigData.configuredFields?.consumerKey, + external_connector_url: (serviceType === 'external' && externalConnectorUrl) || undefined, + external_connector_api_key: + (serviceType === 'external' && externalConnectorApiKey) || undefined, + }; + + try { + const response = await http(route, { + body: JSON.stringify(params), + }); + if (successCallback) successCallback(); + if (isUpdating) { + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceConfigUpdated', + { + defaultMessage: 'Successfully updated configuration.', + } + ) + ); } - : { ...indexPermissionsQuery }; - - try { - const response = await HttpLogic.values.http.get(route, { - query, - }); - actions.setSourceConnectData(response); - successCallback(response.oauthUrl); - } catch (e) { - flashAPIErrors(e); - } finally { - actions.setButtonNotLoading(); - } - }, - getSourceReConnectData: async ({ sourceId }) => { - const { isOrganization } = AppLogic.values; - const route = isOrganization - ? `/internal/workplace_search/org/sources/${sourceId}/reauth_prepare` - : `/internal/workplace_search/account/sources/${sourceId}/reauth_prepare`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setSourceConnectData(response); - } catch (e) { - flashAPIErrors(e); - } - }, - getPreContentSourceConfigData: async () => { - const { isOrganization } = AppLogic.values; - const { preContentSourceId } = values; - const route = isOrganization - ? `/internal/workplace_search/org/pre_sources/${preContentSourceId}` - : `/internal/workplace_search/account/pre_sources/${preContentSourceId}`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setPreContentSourceConfigData(response); - } catch (e) { - flashAPIErrors(e); - } - }, - saveSourceConfig: async ({ isUpdating, successCallback }) => { - clearFlashMessages(); - const { - sourceConfigData: { serviceType }, - baseUrlValue, - clientIdValue, - clientSecretValue, - sourceConfigData, - } = values; - - const { externalConnectorUrl, externalConnectorApiKey } = ExternalConnectorLogic.values; - if ( - serviceType === 'external' && - externalConnectorUrl && - !isValidExternalUrl(externalConnectorUrl) - ) { - ExternalConnectorLogic.actions.setUrlValidation(false); - actions.setButtonNotLoading(); - return; - } - - const route = isUpdating - ? `/internal/workplace_search/org/settings/connectors/${serviceType}` - : '/internal/workplace_search/org/settings/connectors'; - - const http = isUpdating ? HttpLogic.values.http.put : HttpLogic.values.http.post; - - const params = { - base_url: baseUrlValue || undefined, - client_id: clientIdValue || undefined, - client_secret: clientSecretValue || undefined, - service_type: serviceType, - private_key: sourceConfigData.configuredFields?.privateKey, - public_key: sourceConfigData.configuredFields?.publicKey, - consumer_key: sourceConfigData.configuredFields?.consumerKey, - external_connector_url: (serviceType === 'external' && externalConnectorUrl) || undefined, - external_connector_api_key: - (serviceType === 'external' && externalConnectorApiKey) || undefined, - }; - - try { - const response = await http(route, { - body: JSON.stringify(params), - }); - if (successCallback) successCallback(); - if (isUpdating) { - flashSuccessToast( - i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceConfigUpdated', - { - defaultMessage: 'Successfully updated configuration.', - } - ) - ); + actions.setSourceConfigData(response); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); } - actions.setSourceConfigData(response); - } catch (e) { - flashAPIErrors(e); - } finally { - actions.setButtonNotLoading(); - } - }, - saveSourceParams: async ({ search, params, isOrganization }) => { - const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const { setAddedSource } = SourcesLogic.actions; - const query = { ...params }; - const route = '/internal/workplace_search/sources/create'; - - /** + }, + saveSourceParams: async ({ search, params, isOrganization }) => { + const { http } = HttpLogic.values; + const { navigateToUrl } = KibanaLogic.values; + const { setAddedSource } = SourcesLogic.actions; + const query = { ...params }; + const route = '/internal/workplace_search/sources/create'; + + /** There is an extreme edge case where the user is trying to connect Github as source from ent-search, after configuring it in Kibana. When this happens, Github redirects the user from ent-search to Kibana with special error properties in the query params. In this case we need to redirect the user to the app home page and display the error message, and not persist the other query params to the server. */ - if (params.error_description) { - navigateToUrl(isOrganization ? '/' : PRIVATE_SOURCES_PATH); - setErrorMessage( - isOrganization - ? params.error_description - : PERSONAL_DASHBOARD_SOURCE_ERROR(params.error_description) - ); - return; - } - - try { - const response = await http.get<{ - serviceName: string; - indexPermissions: boolean; - serviceType: string; - preContentSourceId: string; - hasConfigureStep: boolean; - }>(route, { query }); - const { serviceName, indexPermissions, serviceType, preContentSourceId, hasConfigureStep } = - response; - - // GitHub requires an intermediate configuration step, where we collect the repos to index. - if (hasConfigureStep && !values.oauthConfigCompleted) { - actions.setPreContentSourceId(preContentSourceId); - navigateToUrl( - getSourcesPath(`${getAddPath('github')}/configure${search}`, isOrganization) + if (params.error_description) { + navigateToUrl(isOrganization ? '/' : PRIVATE_SOURCES_PATH); + setErrorMessage( + isOrganization + ? params.error_description + : PERSONAL_DASHBOARD_SOURCE_ERROR(params.error_description) ); - } else { - setAddedSource(serviceName, indexPermissions, serviceType); + return; + } + + try { + const response = await http.get<{ + serviceName: string; + indexPermissions: boolean; + serviceType: string; + preContentSourceId: string; + hasConfigureStep: boolean; + }>(route, { query }); + const { + serviceName, + indexPermissions, + serviceType, + preContentSourceId, + hasConfigureStep, + } = response; + + // GitHub requires an intermediate configuration step, where we collect the repos to index. + if (hasConfigureStep && !values.oauthConfigCompleted) { + actions.setPreContentSourceId(preContentSourceId); + navigateToUrl( + getSourcesPath(`${getAddPath('github')}/configure${search}`, isOrganization) + ); + } else { + setAddedSource(serviceName, indexPermissions, serviceType); + navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); + } + } catch (e) { navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); + flashAPIErrors(e); } - } catch (e) { - navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); - flashAPIErrors(e); - } - }, - setFirstStep: ({ addSourceProps }) => { - const firstStep = getFirstStep( - addSourceProps, - values.sourceConfigData, - SourcesLogic.values.externalConfigured - ); - actions.setAddSourceStep(firstStep); - }, - createContentSource: async ({ serviceType, successCallback, errorCallback }) => { - clearFlashMessages(); - const { isOrganization } = AppLogic.values; - const route = isOrganization - ? '/internal/workplace_search/org/create_source' - : '/internal/workplace_search/account/create_source'; - - const { - selectedGithubOrganizations: githubOrganizations, - loginValue, - passwordValue, - indexPermissionsValue, - } = values; - - const params = { - service_type: serviceType, - login: loginValue || undefined, - password: passwordValue || undefined, - organizations: githubOrganizations.length > 0 ? githubOrganizations : undefined, - index_permissions: indexPermissionsValue || undefined, - } as { - [key: string]: string | string[] | undefined; - }; - - // Remove undefined values from params - Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); - - try { - await HttpLogic.values.http.post(route, { - body: JSON.stringify({ ...params }), - }); - successCallback(); - } catch (e) { - flashAPIErrors(e); - if (errorCallback) errorCallback(); - } finally { - actions.setButtonNotLoading(); - } - }, - }), -}); - -const getFirstStep = ( - props: AddSourceProps, - sourceConfigData: SourceConfigData, - externalConfigured: boolean -): AddSourceSteps => { + }, + setFirstStep: () => { + const firstStep = getFirstStep(values.sourceConfigData, props.initialStep); + actions.setAddSourceStep(firstStep); + }, + createContentSource: async ({ successCallback, errorCallback }) => { + const { serviceType } = props; + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? '/internal/workplace_search/org/create_source' + : '/internal/workplace_search/account/create_source'; + + const { + selectedGithubOrganizations: githubOrganizations, + loginValue, + passwordValue, + indexPermissionsValue, + } = values; + + const params = { + service_type: serviceType, + login: loginValue || undefined, + password: passwordValue || undefined, + organizations: githubOrganizations.length > 0 ? githubOrganizations : undefined, + index_permissions: indexPermissionsValue || undefined, + } as { + [key: string]: string | string[] | undefined; + }; + + // Remove undefined values from params + Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); + + try { + await HttpLogic.values.http.post(route, { + body: JSON.stringify({ ...params }), + }); + successCallback(); + } catch (e) { + flashAPIErrors(e); + if (errorCallback) errorCallback(); + } finally { + actions.setButtonNotLoading(); + } + }, + }), + } +); + +const getFirstStep = (sourceConfigData: SourceConfigData, initialStep?: string): AddSourceSteps => { const { - connect, - configure, - reAuthenticate, - sourceData: { serviceType, externalConnectorAvailable }, - } = props; - // We can land on this page from a choice page for multiple types of connectors - // If that's the case we want to skip the intro and configuration, if the external & internal connector have already been configured - const { configuredFields, configured } = sourceConfigData; - if (externalConnectorAvailable && configured && externalConfigured) + serviceType, + configured, + configuredFields: { clientId, clientSecret }, + } = sourceConfigData; + if (initialStep === 'connect') return AddSourceSteps.ConnectInstanceStep; + if (initialStep === 'configure') return AddSourceSteps.ConfigureOauthStep; + if (initialStep === 'reauthenticate') return AddSourceSteps.ReauthenticateStep; + if (serviceType !== 'external' && configured) return AddSourceSteps.ConnectInstanceStep; + + // TODO remove this once external/BYO connectors track `configured` properly + if (serviceType === 'external' && clientId && clientSecret) return AddSourceSteps.ConnectInstanceStep; - if (externalConnectorAvailable && !configured && externalConfigured) - return AddSourceSteps.SaveConfigStep; - if (serviceType === 'external') { - // external connectors can be partially configured, so we need to check which fields are filled - if (configuredFields?.clientId && configuredFields?.clientSecret) { - return AddSourceSteps.ConnectInstanceStep; - } - // Unconfigured external connectors have already shown the intro step before the choice page, so we don't want to show it again - return AddSourceSteps.SaveConfigStep; - } - if (connect) return AddSourceSteps.ConnectInstanceStep; - if (configure) return AddSourceSteps.ConfigureOauthStep; - if (reAuthenticate) return AddSourceSteps.ReauthenticateStep; - return AddSourceSteps.ConfigIntroStep; + + return AddSourceSteps.SaveConfigStep; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx index 06815ab3330f0d..a44b5f54852c93 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx @@ -26,7 +26,7 @@ describe('AvailableSourcesList', () => { const wrapper = shallow(); expect(wrapper.find(EuiTitle)).toHaveLength(1); - expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(24); + expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(25); expect(wrapper.find('[data-test-subj="CustomAPISourceLink"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx index 7dc9ad9ca0f60e..9a2787d7790708 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx @@ -18,6 +18,7 @@ import { EuiTitle, EuiText, EuiToolTip, + EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -43,8 +44,13 @@ interface AvailableSourcesListProps { export const AvailableSourcesList: React.FC = ({ sources }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); - const getSourceCard = ({ name, serviceType, accountContextOnly }: SourceDataItem) => { - const addPath = getAddPath(serviceType); + const getSourceCard = ({ + accountContextOnly, + baseServiceType, + name, + serviceType, + }: SourceDataItem) => { + const addPath = getAddPath(serviceType, baseServiceType); const disabled = !hasPlatinumLicense && accountContextOnly; const connectButton = () => { @@ -61,15 +67,30 @@ export const AvailableSourcesList: React.FC = ({ sour } )} > - - Connect - + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.availableSourceList.connectButtonLabel', + { + defaultMessage: 'Connect', + } + )} + ); } else { return ( - - Connect + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.availableSourceList.connectButtonLabel', + { + defaultMessage: 'Connect', + } + )} ); } @@ -79,7 +100,7 @@ export const AvailableSourcesList: React.FC = ({ sour <> - + {name} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx index 94821c0561cf4c..0ed33a01d606f3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx @@ -5,20 +5,19 @@ * 2.0. */ -import { mockKibanaValues, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; import React from 'react'; import { mount } from 'enzyme'; -import { EuiButton } from '@elastic/eui'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; import { staticSourceData } from '../../source_data'; import { ConfigurationChoice } from './configuration_choice'; describe('ConfigurationChoice', () => { - const { navigateToUrl } = mockKibanaValues; const props = { sourceData: staticSourceData[0], }; @@ -28,31 +27,23 @@ describe('ConfigurationChoice', () => { categories: [], }, }; + const mockActions = { + initializeSources: jest.fn(), + resetSourcesState: jest.fn(), + }; beforeEach(() => { - setMockValues(mockValues); jest.clearAllMocks(); + setMockValues(mockValues); + setMockActions(mockActions); }); it('renders internal connector if available', () => { const wrapper = mount(); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); - }); - it('should navigate to internal connector on internal connector click', () => { - const wrapper = mount(); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/internal/'); - }); - it('should call prop function when provided on internal connector click', () => { - const advanceSpy = jest.fn(); - const wrapper = mount(); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).not.toHaveBeenCalled(); - expect(advanceSpy).toHaveBeenCalled(); + const internalConnectorCard = wrapper.find('[data-test-subj="InternalConnectorCard"]'); + expect(internalConnectorCard).toHaveLength(1); + expect(internalConnectorCard.find(EuiButtonTo).prop('to')).toEqual('/sources/add/box/'); }); it('renders external connector if available', () => { @@ -62,32 +53,36 @@ describe('ConfigurationChoice', () => { ...props, sourceData: { ...props.sourceData, - internalConnectorAvailable: false, - externalConnectorAvailable: true, + serviceType: 'share_point', }, }} /> ); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); + const externalConnectorCard = wrapper.find('[data-test-subj="ExternalConnectorCard"]'); + expect(externalConnectorCard).toHaveLength(1); + expect(externalConnectorCard.find(EuiButtonTo).prop('to')).toEqual( + '/sources/add/share_point/external/connector_registration' + ); }); - it('should navigate to external connector on external connector click', () => { + + it('renders disabled message if external connector is available but user has already configured', () => { + setMockValues({ ...mockValues, externalConfigured: true }); + const wrapper = mount( ); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/external/'); + + const externalConnectorCard = wrapper.find('[data-test-subj="ExternalConnectorCard"]'); + expect(externalConnectorCard.prop('disabledMessage')).toBeDefined(); }); it('renders custom connector if available', () => { @@ -97,33 +92,16 @@ describe('ConfigurationChoice', () => { ...props, sourceData: { ...props.sourceData, - internalConnectorAvailable: false, - externalConnectorAvailable: false, - customConnectorAvailable: true, + serviceType: 'share_point_server', }, }} /> ); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); - }); - it('should navigate to custom connector on custom connector click', () => { - const wrapper = mount( - + const customConnectorCard = wrapper.find('[data-test-subj="CustomConnectorCard"]'); + expect(customConnectorCard).toHaveLength(1); + expect(customConnectorCard.find(EuiButtonTo).prop('to')).toEqual( + '/sources/add/share_point_server/custom' ); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/custom/'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx index 8d8311d2a0a6f1..7d5721d8547d2a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -5,92 +5,85 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; + +import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { EuiButton, EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { KibanaLogic } from '../../../../../shared/kibana'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; import { AppLogic } from '../../../../app_logic'; import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; -import { AddSourceHeader } from './add_source_header'; -import { AddSourceLogic } from './add_source_logic'; +import { hasCustomConnectorOption, hasExternalConnectorOption } from '../../source_data'; -interface ConfigurationChoiceProps { - sourceData: SourceDataItem; - goToInternalStep?: () => void; -} +import { SourcesLogic } from '../../sources_logic'; + +import { AddSourceHeader } from './add_source_header'; interface CardProps { title: string; description: string; buttonText: string; - onClick: () => void; + to: string; badgeLabel?: string; + disabledMessage?: string; +} + +const ConnectorCard: React.FC = ({ + title, + description, + buttonText, + to, + badgeLabel, + disabledMessage, +}: CardProps) => ( + + + {buttonText} + + } + /> + +); + +interface ConfigurationChoiceProps { + sourceData: SourceDataItem; } export const ConfigurationChoice: React.FC = ({ - sourceData: { - name, - serviceType, - externalConnectorAvailable, - internalConnectorAvailable, - customConnectorAvailable, - }, - goToInternalStep, + sourceData: { name, categories = [], serviceType }, }) => { + const externalConnectorAvailable = hasExternalConnectorOption(serviceType); + const customConnectorAvailable = hasCustomConnectorOption(serviceType); + const { isOrganization } = useValues(AppLogic); - const { sourceConfigData } = useValues(AddSourceLogic); - const { categories } = sourceConfigData; - const goToInternal = goToInternalStep - ? goToInternalStep - : () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/internal`, - isOrganization - )}/` - ); - const goToExternal = () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/external`, - isOrganization - )}/` - ); - const goToCustom = () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/custom`, - isOrganization - )}/` - ); - - const ConnectorCard: React.FC = ({ - title, - description, - buttonText, - onClick, - badgeLabel, - }: CardProps) => ( - - - {buttonText} - - } - /> - - ); + + const { initializeSources, resetSourcesState } = useActions(SourcesLogic); + + const { externalConfigured } = useValues(SourcesLogic); + + useEffect(() => { + initializeSources(); + return resetSourcesState; + }, []); + + const internalTo = `${getSourcesPath(getAddPath(serviceType), isOrganization)}/`; + const externalTo = `${getSourcesPath( + getAddPath('external', serviceType), + isOrganization + )}/connector_registration`; + const customTo = `${getSourcesPath(getAddPath('custom', serviceType), isOrganization)}`; const internalConnectorProps: CardProps = { title: i18n.translate( @@ -118,7 +111,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Recommended', } ), - onClick: goToInternal, + to: internalTo, }; const externalConnectorProps: CardProps = { @@ -141,7 +134,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Instructions', } ), - onClick: goToExternal, + to: externalTo, badgeLabel: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel', { @@ -169,7 +162,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Instructions', } ), - onClick: goToCustom, + to: customTo, }; return ( @@ -177,9 +170,26 @@ export const ConfigurationChoice: React.FC = ({ - {internalConnectorAvailable && } - {externalConnectorAvailable && } - {customConnectorAvailable && } + + {externalConnectorAvailable && ( + + )} + {customConnectorAvailable && ( + + )} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx index b3ce53a0321dc2..0f1beff70735cd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx @@ -14,11 +14,10 @@ import { EuiText, EuiTitle } from '@elastic/eui'; import { ConfigurationIntro } from './configuration_intro'; describe('ConfigurationIntro', () => { - const advanceStep = jest.fn(); const props = { header:

Header

, name: 'foo', - advanceStep, + advanceStepTo: '', }; it('renderscontext', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx index 5c52537d4a7384..e5da9f6e003167 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { EuiBadge, - EuiButton, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -18,9 +17,12 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; + import connectionIllustration from '../../../../assets/connection_illustration.svg'; import { @@ -37,12 +39,12 @@ import { interface ConfigurationIntroProps { header: React.ReactNode; name: string; - advanceStep(): void; + advanceStepTo: string; } export const ConfigurationIntro: React.FC = ({ name, - advanceStep, + advanceStepTo, header, }) => ( <> @@ -144,11 +146,11 @@ export const ConfigurationIntro: React.FC = ({ - {i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configIntro.configure.button', @@ -157,7 +159,7 @@ export const ConfigurationIntro: React.FC = ({ values: { name }, } )} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx index 332456cae99ad0..c776723377f441 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx @@ -22,7 +22,7 @@ describe('ConfigureOauth', () => { const onFormCreated = jest.fn(); const getPreContentSourceConfigData = jest.fn(); const setSelectedGithubOrganizations = jest.fn(); - const createContentSource = jest.fn((_, formSubmitSuccess, handleFormSubmitError) => { + const createContentSource = jest.fn((formSubmitSuccess, handleFormSubmitError) => { formSubmitSuccess(); handleFormSubmitError(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx index ce5a92a19e3875..af50e8267da2f3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx @@ -35,12 +35,8 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea const { getPreContentSourceConfigData, setSelectedGithubOrganizations, createContentSource } = useActions(AddSourceLogic); - const { - currentServiceType, - githubOrganizations, - selectedGithubOrganizationsMap, - sectionLoading, - } = useValues(AddSourceLogic); + const { githubOrganizations, selectedGithubOrganizationsMap, sectionLoading } = + useValues(AddSourceLogic); const checkboxOptions = githubOrganizations.map((item) => ({ id: item, label: item })); @@ -54,7 +50,7 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea const handleFormSubmit = (e: FormEvent) => { setFormLoading(true); e.preventDefault(); - createContentSource(currentServiceType, formSubmitSuccess, handleFormSubmitError); + createContentSource(formSubmitSuccess, handleFormSubmitError); }; const configfieldsForm = ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx index 5b23368289f1ad..998a4c1d53b8ab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx @@ -11,6 +11,8 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiButtonEmpty } from '@elastic/eui'; + import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { ConfiguredSourcesList } from './configured_sources_list'; @@ -24,47 +26,38 @@ describe('ConfiguredSourcesList', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(20); + expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(21); expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(2); - expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(23); - }); - - it('does show connect button for a connected external source', () => { - const wrapper = shallow( - - ); - expect(wrapper.find(EuiButtonEmptyTo)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(24); }); - it('does show connect button for an unconnected external source', () => { + it('shows connect button for an source with multiple connector options that routes to choice page', () => { const wrapper = shallow( ); const button = wrapper.find(EuiButtonEmptyTo); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/sources/add/external/connect'); + expect(button.prop('to')).toEqual('/sources/add/share_point/choice'); }); - it('connect button for an unconnected source with multiple connector options routes to choice page', () => { + it('shows connect button for a source without multiple connector options that routes to add page', () => { const wrapper = shallow( { ); const button = wrapper.find(EuiButtonEmptyTo); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/sources/add/share_point/'); + expect(button.prop('to')).toEqual('/sources/add/slack/'); }); - it('connect button for a source with multiple connector options routes to connect page for private sources', () => { + it('disabled when in organization mode and connector is account context only', () => { const wrapper = shallow( ); - const button = wrapper.find(EuiButtonEmptyTo); + const button = wrapper.find(EuiButtonEmpty); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/p/sources/add/share_point/connect'); + expect(button.prop('isDisabled')).toBe(true); }); it('handles empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx index bbec096ae07d8e..820df302725b7d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx @@ -27,7 +27,8 @@ import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; -import { hasMultipleConnectorOptions } from '../../../../utils'; + +import { hasMultipleConnectorOptions } from '../../source_data'; import { CONFIGURED_SOURCES_LIST_UNCONNECTED_TOOLTIP, @@ -72,7 +73,8 @@ export const ConfiguredSourcesList: React.FC = ({ const visibleSources = ( {sources.map((sourceData, i) => { - const { connected, accountContextOnly, name, serviceType, isBeta } = sourceData; + const { connected, accountContextOnly, name, serviceType, isBeta, baseServiceType } = + sourceData; return ( = ({ responsive={false} > - + @@ -128,7 +134,7 @@ export const ConfiguredSourcesList: React.FC = ({ {!connected diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx index 3e850277c0b720..992bb561796fe0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx @@ -33,10 +33,10 @@ describe('ConnectInstance', () => { const setSourcePasswordValue = jest.fn(); const setSourceSubdomainValue = jest.fn(); const setSourceIndexPermissionsValue = jest.fn(); - const getSourceConnectData = jest.fn((_, redirectOauth) => { + const getSourceConnectData = jest.fn((redirectOauth) => { redirectOauth(); }); - const createContentSource = jest.fn((_, redirectFormCreated) => { + const createContentSource = jest.fn((redirectFormCreated) => { redirectFormCreated(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index 352addd8176d84..0a4c1a9692e636 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -46,7 +46,6 @@ export const ConnectInstance: React.FC = ({ features, objTypes, name, - serviceType, needsPermissions, onFormCreated, header, @@ -74,8 +73,8 @@ export const ConnectInstance: React.FC = ({ const redirectOauth = (oauthUrl: string) => window.location.replace(oauthUrl); const redirectFormCreated = () => onFormCreated(name); - const onOauthFormSubmit = () => getSourceConnectData(serviceType, redirectOauth); - const onCredentialsFormSubmit = () => createContentSource(serviceType, redirectFormCreated); + const onOauthFormSubmit = () => getSourceConnectData(redirectOauth); + const onCredentialsFormSubmit = () => createContentSource(redirectFormCreated); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx index edfb2897fce152..7a80c9d6980b5a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx @@ -39,7 +39,7 @@ import { SOURCE_FEATURES_GLOBAL_ACCESS_PERMISSIONS_FEATURE_DESCRIPTION, } from './constants'; -interface ConnectInstanceProps { +interface SourceFeatureProps { features?: Features; objTypes?: string[]; name: string; @@ -47,7 +47,7 @@ interface ConnectInstanceProps { type IncludedFeatureIds = Exclude; -export const SourceFeatures: React.FC = ({ features, objTypes, name }) => { +export const SourceFeatures: React.FC = ({ features, objTypes, name }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); const { isOrganization } = useValues(AppLogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx index afacfd0ccbbf96..017a9eb5b5dd0d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx @@ -24,14 +24,6 @@ const customSource = { name: 'name', }; -const preconfiguredSourceData = { - ...staticCustomSourceData, - serviceType: 'sharepoint-server', - configuration: { - ...staticCustomSourceData.configuration, - githubRepository: 'elastic/sharepoint-server-connector', - }, -}; const mockValues = { sourceData: staticCustomSourceData, }; @@ -44,9 +36,7 @@ describe('CustomSourceDeployment', () => { jest.clearAllMocks(); setMockValues(mockValues); - wrapper = shallow( - - ); + wrapper = shallow(); }); it('contains a source identifier', () => { @@ -69,7 +59,7 @@ describe('CustomSourceDeployment', () => { }); wrapper = shallow( - + ); }); @@ -86,9 +76,7 @@ describe('CustomSourceDeployment', () => { jest.clearAllMocks(); setMockValues(mockValues); - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find(EuiPanel).prop('paddingSize')).toEqual('m'); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx index 7d34783e998a78..8910a8acd0c5a0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx @@ -14,17 +14,30 @@ import { EuiLinkTo } from '../../../../shared/react_router_helpers'; import { API_KEY_LABEL } from '../../../constants'; import { API_KEYS_PATH } from '../../../routes'; -import { ContentSource, CustomSource, SourceDataItem } from '../../../types'; +import { ContentSource, CustomSource } from '../../../types'; + +import { getSourceData } from '../source_data'; import { SourceIdentifier } from './source_identifier'; interface Props { source: ContentSource | CustomSource; - sourceData: SourceDataItem; + baseServiceType?: string; small?: boolean; } -export const CustomSourceDeployment: React.FC = ({ source, sourceData, small = false }) => { +export const CustomSourceDeployment: React.FC = ({ + source, + baseServiceType, + small = false, +}) => { const { name, id } = source; + + const sourceData = getSourceData('custom', baseServiceType); + + if (!sourceData) { + return null; + } + const { configuration: { documentationUrl, githubRepository }, } = sourceData; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx index 9af4eae693d7c8..ae6e516ef7d4af 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx @@ -16,8 +16,6 @@ import { EuiCallOut, EuiConfirmModal, EuiEmptyPrompt, EuiTable } from '@elastic/ import { ComponentLoader } from '../../../components/shared/component_loader'; -import * as SourceData from '../source_data'; - import { CustomSourceDeployment } from './custom_source_deployment'; import { Overview } from './overview'; @@ -144,33 +142,6 @@ describe('Overview', () => { expect(initializeSourceSynchronization).toHaveBeenCalled(); }); - it('uses a base service type if one is provided', () => { - jest.spyOn(SourceData, 'getSourceData'); - setMockValues({ - ...mockValues, - contentSource: { - ...fullContentSources[0], - baseServiceType: 'share_point_server', - }, - }); - - shallow(); - - expect(SourceData.getSourceData).toHaveBeenCalledWith('share_point_server'); - }); - - it('defaults to the regular service tye', () => { - jest.spyOn(SourceData, 'getSourceData'); - setMockValues({ - ...mockValues, - contentSource: fullContentSources[0], - }); - - shallow(); - - expect(SourceData.getSourceData).toHaveBeenCalledWith('custom'); - }); - describe('custom sources', () => { it('includes deployment instructions', () => { setMockValues({ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 698dc7a60eea45..ac31ee8314fc8c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -81,7 +81,6 @@ import { SOURCE_SYNC_CONFIRM_TITLE, SOURCE_SYNC_CONFIRM_MESSAGE, } from '../constants'; -import { getSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; import { CustomSourceDeployment } from './custom_source_deployment'; @@ -106,12 +105,10 @@ export const Overview: React.FC = () => { isFederatedSource, isIndexedSource, name, + serviceType, + baseServiceType, } = contentSource; - const serviceType = contentSource.baseServiceType || contentSource.serviceType; - - const sourceData = getSourceData(serviceType); - const [isSyncing, setIsSyncing] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false); const closeModal = () => setIsModalVisible(false); @@ -431,7 +428,7 @@ export const Overview: React.FC = () => { - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index d660b4499e2105..f872648fc101d0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -88,7 +88,7 @@ export const SourceSettings: React.FC = () => { const { isOrganization } = useValues(AppLogic); useEffect(() => { - getSourceConfigData(serviceType); + getSourceConfigData(); }, []); const isGithubApp = diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 282de2590df7f8..0088e80066a02e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -17,9 +17,10 @@ import { } from '../../constants'; import { FeatureIds, SourceDataItem } from '../../types'; -export const staticExternalSourceData: SourceDataItem = { +// TODO remove Sharepoint-specific content after BYO connector support +export const staticGenericExternalSourceData: SourceDataItem = { name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, + categories: [], serviceType: 'external', configuration: { isPublicKey: false, @@ -40,16 +41,12 @@ export const staticExternalSourceData: SourceDataItem = { platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems], }, accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: false, - customConnectorAvailable: false, isBeta: true, }; export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, - iconName: SOURCE_NAMES.BOX, serviceType: 'box', configuration: { isPublicKey: false, @@ -74,11 +71,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE, - iconName: SOURCE_NAMES.CONFLUENCE, serviceType: 'confluence_cloud', configuration: { isPublicKey: false, @@ -108,11 +103,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE_SERVER, - iconName: SOURCE_NAMES.CONFLUENCE_SERVER, serviceType: 'confluence_server', configuration: { isPublicKey: true, @@ -140,11 +133,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.DROPBOX, - iconName: SOURCE_NAMES.DROPBOX, serviceType: 'dropbox', configuration: { isPublicKey: false, @@ -169,11 +160,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB, - iconName: SOURCE_NAMES.GITHUB, serviceType: 'github', configuration: { isPublicKey: false, @@ -205,11 +194,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB_ENTERPRISE, - iconName: SOURCE_NAMES.GITHUB_ENTERPRISE, serviceType: 'github_enterprise_server', configuration: { isPublicKey: false, @@ -247,11 +234,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GMAIL, - iconName: SOURCE_NAMES.GMAIL, serviceType: 'gmail', configuration: { isPublicKey: false, @@ -265,11 +250,9 @@ export const staticSourceData: SourceDataItem[] = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GOOGLE_DRIVE, - iconName: SOURCE_NAMES.GOOGLE_DRIVE, serviceType: 'google_drive', configuration: { isPublicKey: false, @@ -298,11 +281,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA, - iconName: SOURCE_NAMES.JIRA, serviceType: 'jira_cloud', configuration: { isPublicKey: false, @@ -334,11 +315,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA_SERVER, - iconName: SOURCE_NAMES.JIRA_SERVER, serviceType: 'jira_server', configuration: { isPublicKey: true, @@ -369,13 +348,12 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.NETWORK_DRVE, - iconName: SOURCE_NAMES.NETWORK_DRVE, categories: [SOURCE_CATEGORIES.STORAGE], - serviceType: 'network_drive', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'network_drive', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -385,12 +363,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-network-drive-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.ONEDRIVE, - iconName: SOURCE_NAMES.ONEDRIVE, serviceType: 'one_drive', configuration: { isPublicKey: false, @@ -415,17 +390,16 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.OUTLOOK, - iconName: SOURCE_NAMES.OUTLOOK, categories: [ SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], - serviceType: 'outlook', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'outlook', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -435,12 +409,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-outlook-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE, - iconName: SOURCE_NAMES.SALESFORCE, serviceType: 'salesforce', configuration: { isPublicKey: false, @@ -472,11 +443,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE_SANDBOX, - iconName: SOURCE_NAMES.SALESFORCE_SANDBOX, serviceType: 'salesforce_sandbox', configuration: { isPublicKey: false, @@ -508,11 +477,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SERVICENOW, - iconName: SOURCE_NAMES.SERVICENOW, serviceType: 'service_now', configuration: { isPublicKey: false, @@ -541,11 +508,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, serviceType: 'share_point', configuration: { isPublicKey: false, @@ -570,13 +535,39 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: true, }, - staticExternalSourceData, + { + name: SOURCE_NAMES.SHAREPOINT, + categories: [], + serviceType: 'external', + baseServiceType: 'share_point', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.ALL_STORED_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [ + FeatureIds.Private, + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + ], + }, + accountContextOnly: false, + isBeta: true, + }, { name: SOURCE_NAMES.SHAREPOINT_SERVER, - iconName: SOURCE_NAMES.SHAREPOINT_SERVER, categories: [ SOURCE_CATEGORIES.FILE_SHARING, SOURCE_CATEGORIES.STORAGE, @@ -584,7 +575,8 @@ export const staticSourceData: SourceDataItem[] = [ SOURCE_CATEGORIES.MICROSOFT, SOURCE_CATEGORIES.OFFICE_365, ], - serviceType: 'share_point_server', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'share_point_server', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -594,12 +586,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-sharepoint-server-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.SLACK, - iconName: SOURCE_NAMES.SLACK, serviceType: 'slack', configuration: { isPublicKey: false, @@ -617,17 +606,16 @@ export const staticSourceData: SourceDataItem[] = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.TEAMS, - iconName: SOURCE_NAMES.TEAMS, categories: [ SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], - serviceType: 'teams', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'teams', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -637,12 +625,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-teams-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.ZENDESK, - iconName: SOURCE_NAMES.ZENDESK, serviceType: 'zendesk', configuration: { isPublicKey: false, @@ -667,13 +652,12 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.ZOOM, - iconName: SOURCE_NAMES.ZOOM, categories: [SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY], - serviceType: 'zoom', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'zoom', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -683,14 +667,12 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-zoom-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, + staticGenericExternalSourceData, ]; export const staticCustomSourceData: SourceDataItem = { name: SOURCE_NAMES.CUSTOM, - iconName: SOURCE_NAMES.CUSTOM, categories: ['API', 'Custom'], serviceType: 'custom', configuration: { @@ -701,12 +683,26 @@ export const staticCustomSourceData: SourceDataItem = { applicationPortalUrl: '', }, accountContextOnly: false, - customConnectorAvailable: true, }; -export const getSourceData = (serviceType: string): SourceDataItem => { - return ( - staticSourceData.find((staticSource) => staticSource.serviceType === serviceType) || - staticCustomSourceData +export const getSourceData = ( + serviceType: string, + baseServiceType?: string +): SourceDataItem | undefined => { + if (serviceType === 'custom' && typeof baseServiceType === 'undefined') { + return staticCustomSourceData; + } + return staticSourceData.find( + (staticSource) => + staticSource.serviceType === serviceType && staticSource.baseServiceType === baseServiceType ); }; + +export const hasExternalConnectorOption = (serviceType: string): boolean => + !!getSourceData('external', serviceType); + +export const hasCustomConnectorOption = (serviceType: string): boolean => + !!getSourceData('custom', serviceType); + +export const hasMultipleConnectorOptions = (serviceType: string): boolean => + hasExternalConnectorOption(serviceType) || hasCustomConnectorOption(serviceType); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts index 0f113ad402f284..0fdb827f6011df 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts @@ -23,7 +23,12 @@ import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers'; import { AppLogic } from '../../app_logic'; import { staticSourceData } from './source_data'; -import { SourcesLogic, fetchSourceStatuses, POLLING_INTERVAL } from './sources_logic'; +import { + SourcesLogic, + fetchSourceStatuses, + POLLING_INTERVAL, + mergeServerAndStaticData, +} from './sources_logic'; describe('SourcesLogic', () => { const { http } = mockHttpValues; @@ -37,8 +42,14 @@ describe('SourcesLogic', () => { const defaultValues = { contentSources: [], privateContentSources: [], - sourceData: staticSourceData.map((data) => ({ ...data, connected: false })), - availableSources: staticSourceData.map((data) => ({ ...data, connected: false })), + sourceData: mergeServerAndStaticData([], staticSourceData, []).map((data) => ({ + ...data, + connected: false, + })), + availableSources: mergeServerAndStaticData([], staticSourceData, []).map((data) => ({ + ...data, + connected: false, + })), configuredSources: [], serviceTypes: [], permissionsModal: null, @@ -322,7 +333,7 @@ describe('SourcesLogic', () => { it('availableSources & configuredSources have correct length', () => { SourcesLogic.actions.onInitializeSources(serverResponse); - expect(SourcesLogic.values.availableSources).toHaveLength(18); + expect(SourcesLogic.values.availableSources).toHaveLength(19); expect(SourcesLogic.values.configuredSources).toHaveLength(5); }); it('externalConfigured is set to true if external is configured', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 868831ab7c7fb8..0f61ee580f6773 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -51,7 +51,7 @@ export interface IPermissionsModalProps { additionalConfiguration: boolean; } -type CombinedDataItem = SourceDataItem & { connected: boolean }; +type CombinedDataItem = SourceDataItem & Partial & { connected: boolean }; export interface ISourcesValues { contentSources: ContentSourceDetails[]; @@ -145,17 +145,17 @@ export const SourcesLogic = kea>( selectors: ({ selectors }) => ({ availableSources: [ () => [selectors.sourceData], - (sourceData: SourceDataItem[]) => + (sourceData: CombinedDataItem[]) => sortByName(sourceData.filter(({ configured }) => !configured)), ], configuredSources: [ () => [selectors.sourceData], - (sourceData: SourceDataItem[]) => + (sourceData: CombinedDataItem[]) => sortByName(sourceData.filter(({ configured }) => configured)), ], externalConfigured: [ () => [selectors.configuredSources], - (configuredSources: SourceDataItem[]) => + (configuredSources: CombinedDataItem[]) => !!configuredSources.find((item) => item.serviceType === 'external'), ], sourceData: [ @@ -312,9 +312,12 @@ export const mergeServerAndStaticData = ( contentSources: ContentSourceDetails[] ): CombinedDataItem[] => { const unsortedData = staticData.map((staticItem) => { - const serverItem = serverData.find(({ serviceType }) => serviceType === staticItem.serviceType); + const serverItem = staticItem.baseServiceType + ? undefined // static items with base service types will never have matching external connectors, BE doesn't pass us a baseServiceType + : serverData.find(({ serviceType }) => serviceType === staticItem.serviceType); const connectedSource = contentSources.find( - ({ serviceType }) => serviceType === staticItem.serviceType + ({ baseServiceType, serviceType }) => + serviceType === staticItem.serviceType && baseServiceType === staticItem.baseServiceType ); return { ...staticItem, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx index 0fa263beab5395..07baa82a5cdb0c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx @@ -10,11 +10,11 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; import React from 'react'; -import { Route, Switch, Redirect } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { ADD_SOURCE_PATH, PRIVATE_SOURCES_PATH, SOURCES_PATH, getSourcesPath } from '../../routes'; +import { ADD_SOURCE_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../routes'; import { SourcesRouter } from './sources_router'; @@ -34,19 +34,13 @@ describe('SourcesRouter', () => { }); it('renders sources routes', () => { - const TOTAL_ROUTES = 103; const wrapper = shallow(); - expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(TOTAL_ROUTES); - }); - - it('redirects when nonplatinum license and accountOnly context', () => { - setMockValues({ ...mockValues, hasPlatinumLicense: false }); - const wrapper = shallow(); - - expect(wrapper.find(Redirect).last().prop('from')).toEqual(ADD_SOURCE_PATH); - expect(wrapper.find(Redirect).last().prop('to')).toEqual(SOURCES_PATH); + expect(wrapper.find('[data-test-subj="ConnectorIntroRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ConnectorChoiceRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ExternalConnectorConfigRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="AddCustomSourceRoute"]')).toHaveLength(2); + expect(wrapper.find('[data-test-subj="AddSourceRoute"]')).toHaveLength(1); }); it('redirects when cannot create sources', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index 19af955f8780ca..4d4ec077213a01 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -11,7 +11,6 @@ import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import { Location } from 'history'; import { useActions, useValues } from 'kea'; -import { LicensingLogic } from '../../../shared/licensing'; import { AppLogic } from '../../app_logic'; import { GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, @@ -24,17 +23,15 @@ import { SOURCES_PATH, getSourcesPath, getAddPath, - ADD_CUSTOM_PATH, } from '../../routes'; -import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; import { AddCustomSource } from './components/add_source/add_custom_source'; import { ExternalConnectorConfig } from './components/add_source/add_external_connector'; -import { ConfigurationChoice } from './components/add_source/configuration_choice'; +import { AddSourceChoice } from './components/add_source/add_source_choice'; +import { AddSourceIntro } from './components/add_source/add_source_intro'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; -import { staticCustomSourceData, staticSourceData as sources } from './source_data'; import { SourceRouter } from './source_router'; import { SourcesLogic } from './sources_logic'; @@ -42,7 +39,6 @@ import './sources.scss'; export const SourcesRouter: React.FC = () => { const { pathname } = useLocation() as Location; - const { hasPlatinumLicense } = useValues(LicensingLogic); const { resetSourcesState } = useActions(SourcesLogic); const { account: { canCreatePrivateSources }, @@ -82,119 +78,51 @@ export const SourcesRouter: React.FC = () => { - {sources.map((sourceData, i) => { - const { serviceType, externalConnectorAvailable, internalConnectorAvailable } = sourceData; - const path = `${getSourcesPath(getAddPath(serviceType), isOrganization)}`; - const defaultOption = internalConnectorAvailable - ? 'internal' - : externalConnectorAvailable - ? 'external' - : 'custom'; - const showChoice = defaultOption !== 'internal' && hasMultipleConnectorOptions(sourceData); - return ( - - {showChoice ? ( - - ) : ( - - )} - - ); - })} - - + + + + + + + + + + + + + + + + + - {sources - .filter((sourceData) => sourceData.internalConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources - .filter((sourceData) => sourceData.externalConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources - .filter((sourceData) => sourceData.customConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources.map((sourceData, i) => ( - - - - ))} - {sources.map((sourceData, i) => ( - - - - ))} - {sources.map((sourceData, i) => { - if (sourceData.configuration.needsConfiguration) - return ( - - - - ); - })} {canCreatePrivateSources ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index 8399df946ea83c..bc457ca0a1c00d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -8,6 +8,7 @@ import '../../../../__mocks__/shallow_useeffect.mock'; import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -18,8 +19,6 @@ import { EuiCallOut, EuiConfirmModal } from '@elastic/eui'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; -import { staticSourceData } from '../../content_sources/source_data'; - import { SourceConfig } from './source_config'; describe('SourceConfig', () => { @@ -30,10 +29,11 @@ describe('SourceConfig', () => { beforeEach(() => { setMockValues({ sourceConfigData, dataLoading: false }); setMockActions({ deleteSourceConfig, getSourceConfigData, saveSourceConfig }); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -43,15 +43,23 @@ describe('SourceConfig', () => { expect(wrapper.find(EuiCallOut)).toHaveLength(0); }); + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']); }); it('handles delete click', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -63,7 +71,7 @@ describe('SourceConfig', () => { }); it('saves source config', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -75,7 +83,7 @@ describe('SourceConfig', () => { }); it('cancels and closes modal', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -87,9 +95,8 @@ describe('SourceConfig', () => { }); it('shows feedback link for external sources', () => { - const wrapper = shallow( - - ); + mockUseParams.mockReturnValue({ serviceType: 'external' }); + const wrapper = shallow(); expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index 6973732fa6727b..76ed6023109d23 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -7,6 +7,8 @@ import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { @@ -21,29 +23,34 @@ import { i18n } from '@kbn/i18n'; import { WorkplaceSearchPageTemplate } from '../../../components/layout'; import { NAV, REMOVE_BUTTON, CANCEL_BUTTON } from '../../../constants'; -import { SourceDataItem } from '../../../types'; import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header'; import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; +import { getSourceData } from '../../content_sources/source_data'; import { SettingsLogic } from '../settings_logic'; -interface SourceConfigProps { - sourceData: SourceDataItem; -} - -export const SourceConfig: React.FC = ({ sourceData }) => { +export const SourceConfig: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); const [confirmModalVisible, setConfirmModalVisibility] = useState(false); - const { configuration, serviceType } = sourceData; + const addSourceLogic = AddSourceLogic({ serviceType }); const { deleteSourceConfig } = useActions(SettingsLogic); - const { saveSourceConfig, getSourceConfigData } = useActions(AddSourceLogic); + const { saveSourceConfig, getSourceConfigData, resetSourceState } = useActions(addSourceLogic); const { sourceConfigData: { name, categories }, dataLoading, - } = useValues(AddSourceLogic); + } = useValues(addSourceLogic); + const sourceData = getSourceData(serviceType); useEffect(() => { - getSourceConfigData(serviceType); - }, []); + getSourceConfigData(); + return resetSourceState; + }, [serviceType]); + + if (!sourceData) { + return null; + } + + const { configuration } = sourceData; const hideConfirmModal = () => setConfirmModalVisibility(false); const showConfirmModal = () => setConfirmModalVisibility(true); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx index 123167f0ad1d06..604c1552157248 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx @@ -10,12 +10,10 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { setMockActions } from '../../../__mocks__/kea_logic'; import React from 'react'; -import { Route, Redirect, Switch } from 'react-router-dom'; +import { Redirect, Switch } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { staticSourceData } from '../content_sources/source_data'; - import { Connectors } from './components/connectors'; import { Customize } from './components/customize'; import { OauthApplication } from './components/oauth_application'; @@ -24,9 +22,6 @@ import { SettingsRouter } from './settings_router'; describe('SettingsRouter', () => { const initializeSettings = jest.fn(); - const NUM_SOURCES = staticSourceData.length; - // Should be 4 routes other than the sources listed: Connectors, Customize, & OauthApplication, & a redirect - const NUM_ROUTES = NUM_SOURCES + 4; beforeEach(() => { setMockActions({ initializeSettings }); @@ -36,11 +31,10 @@ describe('SettingsRouter', () => { const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(NUM_ROUTES); expect(wrapper.find(Redirect)).toHaveLength(1); expect(wrapper.find(Connectors)).toHaveLength(1); expect(wrapper.find(Customize)).toHaveLength(1); expect(wrapper.find(OauthApplication)).toHaveLength(1); - expect(wrapper.find(SourceConfig)).toHaveLength(NUM_SOURCES); + expect(wrapper.find(SourceConfig)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx index 7c5e501d6a2a12..fc250bbfbf4e42 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx @@ -16,7 +16,6 @@ import { ORG_SETTINGS_OAUTH_APPLICATION_PATH, getEditPath, } from '../../routes'; -import { staticSourceData } from '../content_sources/source_data'; import { Connectors } from './components/connectors'; import { Customize } from './components/customize'; @@ -42,11 +41,9 @@ export const SettingsRouter: React.FC = () => { - {staticSourceData.map((sourceData, i) => ( - - - - ))} + + + diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts index f1f849bd3b17b5..a9295ada0dd856 100644 --- a/x-pack/plugins/event_log/server/event_logger.test.ts +++ b/x-pack/plugins/event_log/server/event_logger.test.ts @@ -143,6 +143,15 @@ describe('EventLogger', () => { expect(nanosToMillis(duration)).toBeCloseTo(timeStopValue - timeStartValue); }); + test('can set specific start time in startTiming', () => { + const event: IEvent = {}; + eventLogger.startTiming(event, new Date('2020-01-01T02:00:00.000Z')); + + const timeStart = event.event!.start!; + expect(timeStart).toBeTruthy(); + expect(timeStart).toEqual('2020-01-01T02:00:00.000Z'); + }); + test('timing method endTiming() method works when startTiming() is not called', async () => { const event: IEvent = {}; eventLogger.stopTiming(event); diff --git a/x-pack/plugins/event_log/server/event_logger.ts b/x-pack/plugins/event_log/server/event_logger.ts index 67d9dc61f4e188..14cde6c191fa30 100644 --- a/x-pack/plugins/event_log/server/event_logger.ts +++ b/x-pack/plugins/event_log/server/event_logger.ts @@ -47,11 +47,12 @@ export class EventLogger implements IEventLogger { this.systemLogger = ctorParams.systemLogger; } - startTiming(event: IEvent): void { + startTiming(event: IEvent, startTime?: Date): void { if (event == null) return; event.event = event.event || {}; - event.event.start = new Date().toISOString(); + const start = startTime ?? new Date(); + event.event.start = start.toISOString(); } stopTiming(event: IEvent): void { diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts index 1336245741bd66..3291f162c09df0 100644 --- a/x-pack/plugins/event_log/server/types.ts +++ b/x-pack/plugins/event_log/server/types.ts @@ -66,7 +66,7 @@ export interface IEventLogClient { export interface IEventLogger { logEvent(properties: IEvent): void; - startTiming(event: IEvent): void; + startTiming(event: IEvent, startTime?: Date): void; stopTiming(event: IEvent): void; } diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 68c2a0ecb7d885..d41a08b8b4755b 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -11,8 +11,6 @@ import type { AGENT_TYPE_TEMPORARY, } from '../../constants'; -import type { FullAgentPolicy } from './agent_policy'; - export type AgentType = | typeof AGENT_TYPE_EPHEMERAL | typeof AGENT_TYPE_PERMANENT @@ -41,7 +39,11 @@ export type AgentActionType = export interface NewAgentAction { type: AgentActionType; data?: any; + ack_data?: any; sent_at?: string; + agents: string[]; + created_at?: string; + id?: string; } export interface AgentAction extends NewAgentAction { @@ -49,41 +51,10 @@ export interface AgentAction extends NewAgentAction { data?: any; sent_at?: string; id: string; - agent_id: string; created_at: string; ack_data?: any; } -export interface AgentPolicyAction extends NewAgentAction { - id: string; - type: AgentActionType; - data: { - policy: FullAgentPolicy; - }; - policy_id: string; - policy_revision: number; - created_at: string; - ack_data?: any; -} - -interface CommonAgentActionSOAttributes { - type: AgentActionType; - sent_at?: string; - timestamp?: string; - created_at: string; - data?: string; - ack_data?: string; -} - -export type AgentActionSOAttributes = CommonAgentActionSOAttributes & { - agent_id: string; -}; -export type AgentPolicyActionSOAttributes = CommonAgentActionSOAttributes & { - policy_id: string; - policy_revision: number; -}; -export type BaseAgentActionSOAttributes = AgentActionSOAttributes | AgentPolicyActionSOAttributes; - export interface AgentMetadata { [x: string]: any; } @@ -273,6 +244,17 @@ export interface FleetServerAgentAction { * The Agent IDs the action is intended for. No support for json.RawMessage with the current generator. Could be useful to lazy parse the agent ids */ agents?: string[]; + + /** + * Date when the agent should execute that agent. This field could be altered by Fleet server for progressive rollout of the action. + */ + start_time?: string; + + /** + * Minimun execution duration in seconds, used for progressive rollout of the action. + */ + minimum_execution_duration?: number; + /** * The opaque payload. */ diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 40570bc599053d..aa256db95634ad 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -34,7 +34,7 @@ export interface GetOneAgentResponse { export interface PostNewAgentActionRequest { body: { - action: NewAgentAction; + action: Omit; }; params: { agentId: string; diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index b871f6d4e690b6..eb8b01d831cd51 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -8,7 +8,7 @@ import type { FunctionComponent } from 'react'; import React, { memo, useEffect, useState } from 'react'; import type { AppMountParameters } from '@kbn/core/public'; -import { EuiCode, EuiEmptyPrompt, EuiErrorBoundary, EuiPanel } from '@elastic/eui'; +import { EuiCode, EuiEmptyPrompt, EuiErrorBoundary, EuiPanel, EuiPortal } from '@elastic/eui'; import type { History } from 'history'; import { Router, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -27,7 +27,7 @@ import type { FleetConfigType, FleetStartServices } from '../../plugin'; import { PackageInstallProvider } from '../integrations/hooks'; -import { useAuthz } from './hooks'; +import { useAuthz, useFlyoutContext } from './hooks'; import { ConfigContext, @@ -38,8 +38,15 @@ import { useBreadcrumbs, useStartServices, UIExtensionsContext, + FlyoutContextProvider, } from './hooks'; -import { Error, Loading, FleetSetupLoading } from './components'; +import { + Error, + Loading, + FleetSetupLoading, + AgentEnrollmentFlyout, + FleetServerFlyout, +} from './components'; import type { UIExtensionsStorage } from './types'; import { FLEET_ROUTING_PATHS } from './constants'; @@ -251,7 +258,7 @@ export const FleetAppContext: React.FC<{ notifications={startServices.notifications} theme$={theme$} > - {children} + {children} @@ -295,6 +302,8 @@ const FleetTopNav = memo( export const AppRoutes = memo( ({ setHeaderActionMenu }: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'] }) => { + const flyoutContext = useFlyoutContext(); + return ( <> @@ -343,6 +352,22 @@ export const AppRoutes = memo( }} /> + + {flyoutContext.isEnrollmentFlyoutOpen && ( + + flyoutContext.closeEnrollmentFlyout()} + /> + + )} + + {flyoutContext.isFleetServerFlyoutOpen && ( + + flyoutContext.closeFleetServerFlyout()} /> + + )} ); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/confirm_fleet_server_connection.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/confirm_fleet_server_connection.tsx index 5aa5c01a108a4b..15e86091910197 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/confirm_fleet_server_connection.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/confirm_fleet_server_connection.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useContext } from 'react'; +import React from 'react'; import type { EuiStepProps } from '@elastic/eui'; import { EuiButton, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; @@ -13,7 +13,7 @@ import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { agentFlyoutContext } from '../../../sections/agents'; +import { useFlyoutContext } from '../../../hooks'; export function getConfirmFleetServerConnectionStep({ disabled, @@ -40,7 +40,7 @@ export function getConfirmFleetServerConnectionStep({ const ConfirmFleetServerConnectionStepContent: React.FunctionComponent<{ isFleetServerReady: boolean; }> = ({ isFleetServerReady }) => { - const addAgentFlyout = useContext(agentFlyoutContext); + const flyoutContext = useFlyoutContext(); return isFleetServerReady ? ( <> @@ -53,7 +53,7 @@ const ConfirmFleetServerConnectionStepContent: React.FunctionComponent<{ - + = () => { isOpen: false, }); - const flyoutContext = useContext(agentFlyoutContext); + const flyoutContext = useFlyoutContext(); // Agent actions states const [agentToReassign, setAgentToReassign] = useState(undefined); @@ -330,7 +329,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { // Fleet server unhealthy status const { isUnhealthy: isFleetServerUnhealthy } = useFleetServerUnhealthy(); const onClickAddFleetServer = useCallback(() => { - flyoutContext?.openFleetServerFlyout(); + flyoutContext.openFleetServerFlyout(); }, [flyoutContext]); const columns = [ diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx index 86990d84d51305..409a259f934dd2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useContext } from 'react'; +import React from 'react'; import { EuiButton, EuiButtonEmpty, @@ -17,14 +17,12 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useStartServices } from '../../../../hooks'; - -import { agentFlyoutContext } from '../..'; +import { useFlyoutContext, useStartServices } from '../../../../hooks'; export const EnrollmentRecommendation: React.FunctionComponent<{ showStandaloneTab: () => void; }> = ({ showStandaloneTab }) => { - const flyoutContext = useContext(agentFlyoutContext); + const flyoutContext = useFlyoutContext(); const { docLinks } = useStartServices(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx index 5902f73cae3bc9..0f9a8a3bbdd508 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx @@ -164,7 +164,7 @@ export const FleetServerUpgradeModal: React.FunctionComponent = ({ onClos ), link: ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx index 57da2fcf36d760..78ceb6293d3cea 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx @@ -5,14 +5,21 @@ * 2.0. */ -import React, { createContext, useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { Router, Route, Switch, useHistory } from 'react-router-dom'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPortal } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FLEET_ROUTING_PATHS } from '../../constants'; -import { Loading, Error, AgentEnrollmentFlyout, FleetServerFlyout } from '../../components'; -import { useConfig, useFleetStatus, useBreadcrumbs, useAuthz, useGetSettings } from '../../hooks'; +import { Loading, Error } from '../../components'; +import { + useConfig, + useFleetStatus, + useBreadcrumbs, + useAuthz, + useGetSettings, + useFlyoutContext, +} from '../../hooks'; import { DefaultLayout, WithoutHeaderLayout } from '../../layouts'; import { AgentListPage } from './agent_list_page'; @@ -21,30 +28,16 @@ import { AgentDetailsPage } from './agent_details_page'; import { NoAccessPage } from './error_pages/no_access'; import { FleetServerUpgradeModal } from './components/fleet_server_upgrade_modal'; -// TODO: Move all instances of toggling these flyouts to a global context object to avoid cases in which -// we can render duplicate "stacked" flyouts -export const agentFlyoutContext = createContext< - | { - openEnrollmentFlyout: () => void; - closeEnrollmentFlyout: () => void; - openFleetServerFlyout: () => void; - closeFleetServerFlyout: () => void; - } - | undefined ->(undefined); - export const AgentsApp: React.FunctionComponent = () => { useBreadcrumbs('agent_list'); const history = useHistory(); const { agents } = useConfig(); const hasFleetAllPrivileges = useAuthz().fleet.all; const fleetStatus = useFleetStatus(); + const flyoutContext = useFlyoutContext(); const settings = useGetSettings(); - const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); - const [isFleetServerFlyoutOpen, setIsFleetServerFlyoutOpen] = useState(false); - const [fleetServerModalVisible, setFleetServerModalVisible] = useState(false); const onCloseFleetServerModal = useCallback(() => { setFleetServerModalVisible(false); @@ -100,7 +93,7 @@ export const AgentsApp: React.FunctionComponent = () => { setIsEnrollmentFlyoutOpen(true)} + onClick={() => flyoutContext.openEnrollmentFlyout()} data-test-subj="addAgentBtnTop" > @@ -111,49 +104,24 @@ export const AgentsApp: React.FunctionComponent = () => { ) : undefined; return ( - setIsEnrollmentFlyoutOpen(true), - closeEnrollmentFlyout: () => setIsEnrollmentFlyoutOpen(false), - openFleetServerFlyout: () => setIsFleetServerFlyoutOpen(true), - closeFleetServerFlyout: () => setIsFleetServerFlyoutOpen(false), - }} - > - - - - - - - - {fleetServerModalVisible && ( - - )} - {hasOnlyFleetServerMissingRequirement ? ( - - ) : ( - - )} - - - - - {isEnrollmentFlyoutOpen && ( - - setIsEnrollmentFlyoutOpen(false)} - /> - - )} - - {isFleetServerFlyoutOpen && ( - - setIsFleetServerFlyoutOpen(false)} /> - - )} - - + + + + + + + + {fleetServerModalVisible && ( + + )} + {hasOnlyFleetServerMissingRequirement ? ( + + ) : ( + + )} + + + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx index 717e528443a3f9..fb0d7f625488a5 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx @@ -7,7 +7,7 @@ import React, { memo } from 'react'; import type { AppMountParameters } from '@kbn/core/public'; -import { EuiErrorBoundary } from '@elastic/eui'; +import { EuiErrorBoundary, EuiPortal } from '@elastic/eui'; import type { History } from 'history'; import { Router, Redirect, Route, Switch } from 'react-router-dom'; import useObservable from 'react-use/lib/useObservable'; @@ -22,14 +22,17 @@ import type { FleetConfigType, FleetStartServices } from '../../plugin'; import { ConfigContext, FleetStatusProvider, KibanaVersionContext } from '../../hooks'; -import { AgentPolicyContextProvider } from './hooks'; +import { FleetServerFlyout } from '../fleet/components'; + +import { AgentPolicyContextProvider, useFlyoutContext } from './hooks'; import { INTEGRATIONS_ROUTING_PATHS, pagePathGetters } from './constants'; import type { UIExtensionsStorage } from './types'; import { EPMApp } from './sections/epm'; -import { PackageInstallProvider, UIExtensionsContext } from './hooks'; +import { PackageInstallProvider, UIExtensionsContext, FlyoutContextProvider } from './hooks'; import { IntegrationsHeader } from './components/header'; +import { AgentEnrollmentFlyout } from './components'; const EmptyContext = () => <>; @@ -81,9 +84,11 @@ export const IntegrationsAppContext: React.FC<{ notifications={startServices.notifications} theme$={theme$} > - - {children} - + + + {children} + + @@ -104,6 +109,8 @@ export const IntegrationsAppContext: React.FC<{ ); export const AppRoutes = memo(() => { + const flyoutContext = useFlyoutContext(); + return ( <> @@ -131,6 +138,22 @@ export const AppRoutes = memo(() => { }} /> + + {flyoutContext.isEnrollmentFlyoutOpen && ( + + flyoutContext.closeEnrollmentFlyout()} + /> + + )} + + {flyoutContext.isFleetServerFlyoutOpen && ( + + flyoutContext.closeFleetServerFlyout()} /> + + )} ); }); diff --git a/x-pack/plugins/fleet/public/hooks/index.ts b/x-pack/plugins/fleet/public/hooks/index.ts index 5c995131396b40..579d1ab5bc3de0 100644 --- a/x-pack/plugins/fleet/public/hooks/index.ts +++ b/x-pack/plugins/fleet/public/hooks/index.ts @@ -27,3 +27,4 @@ export * from './use_platform'; export * from './use_agent_policy_refresh'; export * from './use_package_installations'; export * from './use_agent_enrollment_flyout_data'; +export * from './use_flyout_context'; diff --git a/x-pack/plugins/fleet/public/hooks/use_flyout_context.tsx b/x-pack/plugins/fleet/public/hooks/use_flyout_context.tsx new file mode 100644 index 00000000000000..0ddc358ab2fbf1 --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_flyout_context.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, useState } from 'react'; + +const agentFlyoutContext = createContext< + | { + isEnrollmentFlyoutOpen: boolean; + openEnrollmentFlyout: () => void; + closeEnrollmentFlyout: () => void; + isFleetServerFlyoutOpen: boolean; + openFleetServerFlyout: () => void; + closeFleetServerFlyout: () => void; + } + | undefined +>(undefined); + +export const FlyoutContextProvider: React.FunctionComponent = ({ children }) => { + const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); + const [isFleetServerFlyoutOpen, setIsFleetServerFlyoutOpen] = useState(false); + + return ( + setIsEnrollmentFlyoutOpen(true), + closeEnrollmentFlyout: () => setIsEnrollmentFlyoutOpen(false), + isFleetServerFlyoutOpen, + openFleetServerFlyout: () => setIsFleetServerFlyoutOpen(true), + closeFleetServerFlyout: () => setIsFleetServerFlyoutOpen(false), + }} + > + {children} + + ); +}; + +export const useFlyoutContext = () => { + const context = useContext(agentFlyoutContext); + + if (!context) { + throw new Error('useFlyoutContext must be used within a FlyoutContextProvider'); + } + + return context; +}; diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts index 4f3cad9edab262..80a6eac2d81b07 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts @@ -33,7 +33,7 @@ export const postNewAgentActionHandlerBuilder = function ( const savedAgentAction = await actionsService.createAgentAction(esClient, { created_at: new Date().toISOString(), ...newAgentAction, - agent_id: agent.id, + agents: [agent.id], }); const body: PostNewAgentActionResponse = { diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index 3ea8060e8e4923..7a13e1612cb0c8 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -8,20 +8,26 @@ import uuid from 'uuid'; import type { ElasticsearchClient } from '@kbn/core/server'; -import type { Agent, AgentAction, FleetServerAgentAction } from '../../../common/types/models'; +import type { + Agent, + AgentAction, + NewAgentAction, + FleetServerAgentAction, +} from '../../../common/types/models'; import { AGENT_ACTIONS_INDEX } from '../../../common/constants'; const ONE_MONTH_IN_MS = 2592000000; export async function createAgentAction( esClient: ElasticsearchClient, - newAgentAction: Omit + newAgentAction: NewAgentAction ): Promise { - const id = uuid.v4(); + const id = newAgentAction.id ?? uuid.v4(); + const timestamp = new Date().toISOString(); const body: FleetServerAgentAction = { - '@timestamp': new Date().toISOString(), + '@timestamp': timestamp, expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [newAgentAction.agent_id], + agents: newAgentAction.agents, action_id: id, data: newAgentAction.data, type: newAgentAction.type, @@ -37,6 +43,7 @@ export async function createAgentAction( return { id, ...newAgentAction, + created_at: timestamp, }; } @@ -62,7 +69,7 @@ export async function bulkCreateAgentActions( const body: FleetServerAgentAction = { '@timestamp': new Date().toISOString(), expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [action.agent_id], + agents: action.agents, action_id: action.id, data: action.data, type: action.type, diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index d342e8d54bb84c..c842bfb8f72c76 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -20,7 +20,7 @@ import { bulkUpdateAgents, } from './crud'; import type { GetAgentsOptions } from '.'; -import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { createAgentAction } from './actions'; import { searchHitToAgent } from './helpers'; export async function reassignAgent( @@ -42,7 +42,7 @@ export async function reassignAgent( }); await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: new Date().toISOString(), type: 'POLICY_REASSIGN', }); @@ -161,14 +161,11 @@ export async function reassignAgents( }); const now = new Date().toISOString(); - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - type: 'POLICY_REASSIGN', - })) - ); + await createAgentAction(esClient, { + agents: agentsToUpdate.map((agent) => agent.id), + created_at: now, + type: 'POLICY_REASSIGN', + }); return { items: orderedOut }; } diff --git a/x-pack/plugins/fleet/server/services/agents/saved_objects.ts b/x-pack/plugins/fleet/server/services/agents/saved_objects.ts index a26194ef6ddeb9..596c7db5d84727 100644 --- a/x-pack/plugins/fleet/server/services/agents/saved_objects.ts +++ b/x-pack/plugins/fleet/server/services/agents/saved_objects.ts @@ -5,18 +5,9 @@ * 2.0. */ -import Boom from '@hapi/boom'; import type { SavedObject } from '@kbn/core/server'; -import type { - Agent, - AgentSOAttributes, - AgentAction, - AgentPolicyAction, - AgentActionSOAttributes, - AgentPolicyActionSOAttributes, - BaseAgentActionSOAttributes, -} from '../../types'; +import type { Agent, AgentSOAttributes } from '../../types'; export function savedObjectToAgent(so: SavedObject): Agent { if (so.error) { @@ -33,58 +24,3 @@ export function savedObjectToAgent(so: SavedObject): Agent { packages: so.attributes.packages ?? [], }; } - -export function savedObjectToAgentAction(so: SavedObject): AgentAction; -export function savedObjectToAgentAction( - so: SavedObject -): AgentPolicyAction; -export function savedObjectToAgentAction( - so: SavedObject -): AgentAction | AgentPolicyAction { - if (so.error) { - if (so.error.statusCode === 404) { - throw Boom.notFound(so.error.message); - } - - throw new Error(so.error.message); - } - - // If it's an AgentPolicyAction - if (isPolicyActionSavedObject(so)) { - return { - id: so.id, - type: so.attributes.type, - created_at: so.attributes.created_at, - policy_id: so.attributes.policy_id, - policy_revision: so.attributes.policy_revision, - data: so.attributes.data ? JSON.parse(so.attributes.data) : undefined, - ack_data: so.attributes.ack_data ? JSON.parse(so.attributes.ack_data) : undefined, - }; - } - - if (!isAgentActionSavedObject(so)) { - throw new Error(`Malformed saved object AgentAction ${so.id}`); - } - - // If it's an AgentAction - return { - id: so.id, - type: so.attributes.type, - created_at: so.attributes.created_at, - agent_id: so.attributes.agent_id, - data: so.attributes.data ? JSON.parse(so.attributes.data) : undefined, - ack_data: so.attributes.ack_data ? JSON.parse(so.attributes.ack_data) : undefined, - }; -} - -export function isAgentActionSavedObject( - so: SavedObject -): so is SavedObject { - return (so.attributes as AgentActionSOAttributes).agent_id !== undefined; -} - -export function isPolicyActionSavedObject( - so: SavedObject -): so is SavedObject { - return (so.attributes as AgentPolicyActionSOAttributes).policy_id !== undefined; -} diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index e6327c16c3ccc9..45f40916598a15 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -96,7 +96,7 @@ describe('unenrollAgents (plural)', () => { await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -116,7 +116,7 @@ describe('unenrollAgents (plural)', () => { // calls ES update with correct values const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -175,7 +175,7 @@ describe('unenrollAgents (plural)', () => { await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, force: true }); // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -232,18 +232,6 @@ describe('unenrollAgents (plural)', () => { function createClientMock() { const soClientMock = savedObjectsClientMock.create(); - // need to mock .create & bulkCreate due to (bulk)createAgentAction(s) in unenrollAgent(s) - // @ts-expect-error - soClientMock.create.mockResolvedValue({ attributes: { agent_id: 'tata' } }); - soClientMock.bulkCreate.mockImplementation(async ([{ type, attributes }]) => { - return { - saved_objects: [await soClientMock.create(type, attributes)], - }; - }); - soClientMock.bulkUpdate.mockResolvedValue({ - saved_objects: [], - }); - soClientMock.get.mockImplementation(async (_, id) => { switch (id) { case regularAgentPolicySO.id: diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 461caff1ada6c6..92dd0f1ba22f8e 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -11,7 +11,7 @@ import type { Agent, BulkActionResult } from '../../types'; import * as APIKeyService from '../api_keys'; import { HostedAgentPolicyRestrictionRelatedError } from '../../errors'; -import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; import { getAgentById, @@ -53,7 +53,7 @@ export async function unenrollAgent( } const now = new Date().toISOString(); await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: now, type: 'UNENROLL', }); @@ -105,14 +105,11 @@ export async function unenrollAgents( await invalidateAPIKeysForAgents(agentsToUpdate); } else { // Create unenroll action for each agent - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - type: 'UNENROLL', - })) - ); + await createAgentAction(esClient, { + agents: agentsToUpdate.map((agent) => agent.id), + created_at: now, + type: 'UNENROLL', + }); } // Update the necessary agents diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 36568ca6e00041..00470d5e25f8d8 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -17,7 +17,7 @@ import { import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; -import { bulkCreateAgentActions, createAgentAction } from './actions'; +import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; import { getAgentDocuments, @@ -59,7 +59,7 @@ export async function sendUpgradeAgentAction({ } await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: now, data, ack_data: data, @@ -75,8 +75,8 @@ export async function sendUpgradeAgentsActions( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, options: ({ agents: Agent[] } | GetAgentsOptions) & { - sourceUri: string | undefined; version: string; + sourceUri?: string | undefined; force?: boolean; } ) { @@ -158,16 +158,13 @@ export async function sendUpgradeAgentsActions( source_uri: options.sourceUri, }; - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - data, - ack_data: data, - type: 'UPGRADE', - })) - ); + await createAgentAction(esClient, { + created_at: now, + data, + ack_data: data, + type: 'UPGRADE', + agents: agentsToUpdate.map((agent) => agent.id), + }); await bulkUpdateAgents( esClient, diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 6356ff8aa6cac5..37dde581d4b8f0 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -12,10 +12,6 @@ export type { AgentStatus, AgentType, AgentAction, - AgentPolicyAction, - BaseAgentActionSOAttributes, - AgentActionSOAttributes, - AgentPolicyActionSOAttributes, PackagePolicy, PackagePolicyInput, PackagePolicyInputStream, diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable.ts index 84aba75239ed0d..4f04fc1753c826 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/datatable.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/datatable.ts @@ -39,7 +39,7 @@ export const getDatatable = ( ): DatatableExpressionFunction => ({ name: 'lens_datatable', type: 'render', - inputTypes: ['lens_multitable'], + inputTypes: ['datatable'], help: i18n.translate('xpack.lens.datatable.expressionHelpLabel', { defaultMessage: 'Datatable renderer', }), diff --git a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts index 713f929d744204..464c70a4123972 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/datatable_fn.ts @@ -8,8 +8,8 @@ import { cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; -import type { DatatableColumnMeta, ExecutionContext } from '@kbn/expressions-plugin'; -import { FormatFactory, LensMultiTable } from '../../types'; +import type { Datatable, DatatableColumnMeta, ExecutionContext } from '@kbn/expressions-plugin'; +import { FormatFactory } from '../../types'; import { transposeTable } from './transpose_helpers'; import { computeSummaryRowForColumn } from './summary'; import { getSortingCriteria } from './sorting'; @@ -23,11 +23,13 @@ export const datatableFn = ( getFormatFactory: (context: ExecutionContext) => FormatFactory | Promise ): DatatableExpressionFunction['fn'] => - async (data, args, context) => { - const [firstTable] = Object.values(data.tables); + async (table, args, context) => { if (context?.inspectorAdapters?.tables) { + context.inspectorAdapters.tables.reset(); + context.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( - Object.values(data.tables)[0], + table, [ [ args.columns.map((column) => column.columnId), @@ -42,27 +44,27 @@ export const datatableFn = context.inspectorAdapters.tables.logDatatable('default', logTable); } - let untransposedData: LensMultiTable | undefined; + let untransposedData: Datatable | undefined; // do the sorting at this level to propagate it also at CSV download const [layerId] = Object.keys(context.inspectorAdapters.tables || {}); const formatters: Record> = {}; const formatFactory = await getFormatFactory(context); - firstTable.columns.forEach((column) => { + table.columns.forEach((column) => { formatters[column.id] = formatFactory(column.meta?.params); }); const hasTransposedColumns = args.columns.some((c) => c.isTransposed); if (hasTransposedColumns) { // store original shape of data separately - untransposedData = cloneDeep(data); + untransposedData = cloneDeep(table); // transposes table and args inplace - transposeTable(args, firstTable, formatters); + transposeTable(args, table, formatters); } const { sortingColumnId: sortBy, sortingDirection: sortDirection } = args; - const columnsReverseLookup = firstTable.columns.reduce< + const columnsReverseLookup = table.columns.reduce< Record >((memo, { id, name, meta }, i) => { memo[id] = { name, index: i, meta }; @@ -73,7 +75,7 @@ export const datatableFn = for (const column of columnsWithSummary) { column.summaryRowValue = computeSummaryRowForColumn( column, - firstTable, + table, formatters, formatFactory({ id: 'number' }) ); @@ -92,20 +94,21 @@ export const datatableFn = sortDirection ); // replace the table here - context.inspectorAdapters.tables[layerId].rows = (firstTable.rows || []) + context.inspectorAdapters.tables[layerId].rows = (table.rows || []) .slice() .sort(sortingCriteria); // replace also the local copy - firstTable.rows = context.inspectorAdapters.tables[layerId].rows; + table.rows = context.inspectorAdapters.tables[layerId].rows; } else { args.sortingColumnId = undefined; args.sortingDirection = 'none'; } + return { type: 'render', as: 'lens_datatable_renderer', value: { - data, + data: table, untransposedData, args, }, diff --git a/x-pack/plugins/lens/common/expressions/datatable/types.ts b/x-pack/plugins/lens/common/expressions/datatable/types.ts index 9e6315bb856d0b..a78018fca90f64 100644 --- a/x-pack/plugins/lens/common/expressions/datatable/types.ts +++ b/x-pack/plugins/lens/common/expressions/datatable/types.ts @@ -5,13 +5,12 @@ * 2.0. */ -import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin'; -import type { LensMultiTable } from '../../types'; +import type { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin'; import type { DatatableArgs } from './datatable'; export interface DatatableProps { - data: LensMultiTable; - untransposedData?: LensMultiTable; + data: Datatable; + untransposedData?: Datatable; args: DatatableArgs; } @@ -23,7 +22,7 @@ export interface DatatableRender { export type DatatableExpressionFunction = ExpressionFunctionDefinition< 'lens_datatable', - LensMultiTable, + Datatable, DatatableArgs, Promise >; diff --git a/x-pack/plugins/lens/common/expressions/expression_types/lens_multitable.ts b/x-pack/plugins/lens/common/expressions/expression_types/lens_multitable.ts deleted file mode 100644 index 2e1ce28534a275..00000000000000 --- a/x-pack/plugins/lens/common/expressions/expression_types/lens_multitable.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ExpressionTypeDefinition } from '@kbn/expressions-plugin/common'; -import { LensMultiTable } from '../../types'; - -const name = 'lens_multitable'; - -type Input = LensMultiTable; - -export type LensMultitableExpressionTypeDefinition = ExpressionTypeDefinition< - typeof name, - Input, - Input ->; - -export const lensMultitable: LensMultitableExpressionTypeDefinition = { - name, - to: { - datatable: (input: Input) => { - return Object.values(input.tables)[0]; - }, - }, -}; diff --git a/x-pack/plugins/lens/common/expressions/index.ts b/x-pack/plugins/lens/common/expressions/index.ts index 47ff8318447b2f..2007a61b11bf91 100644 --- a/x-pack/plugins/lens/common/expressions/index.ts +++ b/x-pack/plugins/lens/common/expressions/index.ts @@ -8,8 +8,5 @@ export * from './counter_rate'; export * from './format_column'; export * from './rename_columns'; -export * from './merge_tables'; export * from './time_scale'; export * from './datatable'; - -export * from './expression_types'; diff --git a/x-pack/plugins/lens/common/expressions/merge_tables/index.ts b/x-pack/plugins/lens/common/expressions/merge_tables/index.ts deleted file mode 100644 index 2e0dbc4b44264d..00000000000000 --- a/x-pack/plugins/lens/common/expressions/merge_tables/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import type { - ExpressionFunctionDefinition, - Datatable, - ExecutionContext, -} from '@kbn/expressions-plugin/common'; -import { toAbsoluteDates } from '@kbn/data-plugin/common'; -import type { ExpressionValueSearchContext } from '@kbn/data-plugin/common'; - -import type { Adapters } from '@kbn/inspector-plugin/common'; -import type { LensMultiTable } from '../../types'; - -interface MergeTables { - layerIds: string[]; - tables: Datatable[]; -} - -export const mergeTables: ExpressionFunctionDefinition< - 'lens_merge_tables', - ExpressionValueSearchContext | null, - MergeTables, - LensMultiTable, - ExecutionContext -> = { - name: 'lens_merge_tables', - type: 'lens_multitable', - help: i18n.translate('xpack.lens.functions.mergeTables.help', { - defaultMessage: - 'A helper to merge any number of kibana tables into a single table and expose it via inspector adapter', - }), - args: { - layerIds: { - types: ['string'], - help: '', - multi: true, - }, - tables: { - types: ['datatable'], - help: '', - multi: true, - }, - }, - inputTypes: ['kibana_context', 'null'], - fn(input, { layerIds, tables }, context) { - const resultTables: Record = {}; - - if (context.inspectorAdapters?.tables) { - context.inspectorAdapters.tables.reset(); - context.inspectorAdapters.tables.allowCsvExport = true; - } - - tables.forEach((table, index) => { - resultTables[layerIds[index]] = table; - }); - - return { - type: 'lens_multitable', - tables: resultTables, - dateRange: getDateRange(input), - }; - }, -}; - -function getDateRange(value?: ExpressionValueSearchContext | null) { - if (!value || !value.timeRange) { - return; - } - - const dateRange = toAbsoluteDates(value.timeRange); - - if (!dateRange) { - return; - } - - return { - fromDate: dateRange.from, - toDate: dateRange.to, - }; -} diff --git a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts b/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts deleted file mode 100644 index 4558bdfe68661a..00000000000000 --- a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import { mergeTables } from '.'; -import type { ExpressionValueSearchContext } from '@kbn/data-plugin/common'; -import { - Datatable, - ExecutionContext, - DefaultInspectorAdapters, - TablesAdapter, -} from '@kbn/expressions-plugin'; - -describe('lens_merge_tables', () => { - const sampleTable1: Datatable = { - type: 'datatable', - columns: [ - { id: 'bucket', name: 'A', meta: { type: 'string' } }, - { id: 'count', name: 'Count', meta: { type: 'number' } }, - ], - rows: [ - { bucket: 'a', count: 5 }, - { bucket: 'b', count: 10 }, - ], - }; - - const sampleTable2: Datatable = { - type: 'datatable', - columns: [ - { id: 'bucket', name: 'C', meta: { type: 'string' } }, - { id: 'avg', name: 'Average', meta: { type: 'number' } }, - ], - rows: [ - { bucket: 'a', avg: 2.5 }, - { bucket: 'b', avg: 9 }, - ], - }; - - it('should produce a row with the nested table as defined', () => { - expect( - mergeTables.fn( - null, - { layerIds: ['first', 'second'], tables: [sampleTable1, sampleTable2] }, - // eslint-disable-next-line - {} as any - ) - ).toEqual({ - tables: { first: sampleTable1, second: sampleTable2 }, - type: 'lens_multitable', - }); - }); - - it('should reset the current tables in the tables inspector', () => { - const adapters = { - tables: new TablesAdapter(), - } as DefaultInspectorAdapters; - - const resetSpy = jest.spyOn(adapters.tables, 'reset'); - - mergeTables.fn(null, { layerIds: ['first', 'second'], tables: [sampleTable1, sampleTable2] }, { - inspectorAdapters: adapters, - } as ExecutionContext); - expect(resetSpy).toHaveBeenCalled(); - }); - - it('should pass the date range along', () => { - expect( - mergeTables.fn( - { - type: 'kibana_context', - timeRange: { - from: '2019-01-01T05:00:00.000Z', - to: '2020-01-01T05:00:00.000Z', - }, - }, - { layerIds: ['first', 'second'], tables: [] }, - // eslint-disable-next-line - {} as any - ) - ).toMatchInlineSnapshot(` - Object { - "dateRange": Object { - "fromDate": 2019-01-01T05:00:00.000Z, - "toDate": 2020-01-01T05:00:00.000Z, - }, - "tables": Object {}, - "type": "lens_multitable", - } - `); - }); - - it('should handle this week now/w', () => { - const { dateRange } = mergeTables.fn( - { - type: 'kibana_context', - timeRange: { - from: 'now/w', - to: 'now/w', - }, - }, - { layerIds: ['first', 'second'], tables: [] }, - // eslint-disable-next-line - {} as any - ); - - expect(moment.duration(moment().startOf('week').diff(dateRange!.fromDate)).asDays()).toEqual(0); - - expect(moment.duration(moment().endOf('week').diff(dateRange!.toDate)).asDays()).toEqual(0); - }); -}); diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 43f4afb9e04848..d7432e0f10b6f7 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -10,7 +10,6 @@ import { Position } from '@elastic/charts'; import { $Values } from '@kbn/utility-types'; import type { CustomPaletteParams, PaletteOutput } from '@kbn/coloring'; import type { IFieldFormat, SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; -import type { Datatable } from '@kbn/expressions-plugin/common'; import type { ColorMode } from '@kbn/charts-plugin/common'; import { LegendSize } from '@kbn/visualizations-plugin/common'; import { @@ -41,15 +40,6 @@ export interface PersistableFilter extends Filter { meta: PersistableFilterMeta; } -export interface LensMultiTable { - type: 'lens_multitable'; - tables: Record; - dateRange?: { - fromDate: Date; - toDate: Date; - }; -} - export type SortingHint = 'version'; export type CustomPaletteParamsConfig = CustomPaletteParams & { diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts index 118abdcb77c8a2..f11fc098b50d92 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts @@ -17,7 +17,6 @@ import { createGridHideHandler, createTransposeColumnFilterHandler, } from './table_actions'; -import { LensMultiTable } from '../../../common'; import { LensGridDirection, ColumnConfig } from '../../../common/expressions'; function getDefaultConfig(): ColumnConfig { @@ -49,17 +48,8 @@ function createTableRef( }; } -function createUntransposedRef(options?: { - withDate: boolean; -}): React.MutableRefObject { - return { - current: { - type: 'lens_multitable', - tables: { - first: createTableRef(options).current, - }, - }, - }; +function createUntransposedRef(options?: { withDate: boolean }): React.MutableRefObject { + return { current: createTableRef(options).current }; } describe('Table actions', () => { @@ -147,13 +137,13 @@ describe('Table actions', () => { it('should set a filter on click with the correct configuration', () => { const onClickValue = jest.fn(); const tableRef = createUntransposedRef({ withDate: true }); - tableRef.current.tables.first.rows = [{ a: 123456 }]; + tableRef.current.rows = [{ a: 123456 }]; const filterHandle = createTransposeColumnFilterHandler(onClickValue, tableRef); filterHandle( [ { - originalBucketColumn: tableRef.current.tables.first.columns[0], + originalBucketColumn: tableRef.current.columns[0], value: 123456, }, ], @@ -164,7 +154,7 @@ describe('Table actions', () => { { column: 0, row: 0, - table: tableRef.current.tables.first, + table: tableRef.current, value: 123456, }, ], @@ -175,13 +165,13 @@ describe('Table actions', () => { it('should set a negate filter on click with the correct configuration', () => { const onClickValue = jest.fn(); const tableRef = createUntransposedRef({ withDate: true }); - tableRef.current.tables.first.rows = [{ a: 123456 }]; + tableRef.current.rows = [{ a: 123456 }]; const filterHandle = createTransposeColumnFilterHandler(onClickValue, tableRef); filterHandle( [ { - originalBucketColumn: tableRef.current.tables.first.columns[0], + originalBucketColumn: tableRef.current.columns[0], value: 123456, }, ], @@ -192,7 +182,7 @@ describe('Table actions', () => { { column: 0, row: 0, - table: tableRef.current.tables.first, + table: tableRef.current, value: 123456, }, ], @@ -204,7 +194,7 @@ describe('Table actions', () => { const onClickValue = jest.fn(); const tableRef = createUntransposedRef({ withDate: false }); const filterHandle = createTransposeColumnFilterHandler(onClickValue, tableRef); - tableRef.current.tables.first.columns = [ + tableRef.current.columns = [ { id: 'a', name: 'a', @@ -220,7 +210,7 @@ describe('Table actions', () => { }, }, ]; - tableRef.current.tables.first.rows = [ + tableRef.current.rows = [ { a: 'a1', b: 'b1', @@ -242,11 +232,11 @@ describe('Table actions', () => { filterHandle( [ { - originalBucketColumn: tableRef.current.tables.first.columns[0], + originalBucketColumn: tableRef.current.columns[0], value: 'a2', }, { - originalBucketColumn: tableRef.current.tables.first.columns[1], + originalBucketColumn: tableRef.current.columns[1], value: 'b3', }, ], @@ -257,13 +247,13 @@ describe('Table actions', () => { { column: 0, row: 1, - table: tableRef.current.tables.first, + table: tableRef.current, value: 'a2', }, { column: 1, row: 2, - table: tableRef.current.tables.first, + table: tableRef.current, value: 'b3', }, ], diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts index 817b1d10fd82e7..ba7e957de083a9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts @@ -7,8 +7,7 @@ import type { EuiDataGridSorting } from '@elastic/eui'; import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin'; -import type { LensFilterEvent } from '../../types'; -import type { LensMultiTable } from '../../../common'; +import { ClickTriggerEvent } from '@kbn/charts-plugin/public'; import type { LensResizeAction, LensSortAction, LensToggleAction } from './types'; import type { ColumnConfig, LensGridDirection } from '../../../common/expressions'; import { getOriginalId } from '../../../common/expressions'; @@ -72,10 +71,10 @@ export const createGridHideHandler = export const createGridFilterHandler = ( tableRef: React.MutableRefObject, - onClickValue: (data: LensFilterEvent['data']) => void + onClickValue: (data: ClickTriggerEvent['data']) => void ) => (field: string, value: unknown, colIndex: number, rowIndex: number, negate: boolean = false) => { - const data: LensFilterEvent['data'] = { + const data: ClickTriggerEvent['data'] = { negate, data: [ { @@ -92,17 +91,17 @@ export const createGridFilterHandler = export const createTransposeColumnFilterHandler = ( - onClickValue: (data: LensFilterEvent['data']) => void, - untransposedDataRef: React.MutableRefObject + onClickValue: (data: ClickTriggerEvent['data']) => void, + untransposedDataRef: React.MutableRefObject ) => ( bucketValues: Array<{ originalBucketColumn: DatatableColumn; value: unknown }>, negate: boolean = false ) => { if (!untransposedDataRef.current) return; - const originalTable = Object.values(untransposedDataRef.current.tables)[0]; + const originalTable = untransposedDataRef.current; - const data: LensFilterEvent['data'] = { + const data: ClickTriggerEvent['data'] = { negate, data: bucketValues.map(({ originalBucketColumn, value }) => { const columnIndex = originalTable.columns.findIndex( diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index 96ffbf371525e2..bcf6f50d2bd466 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -20,59 +20,53 @@ import { VisualizationContainer } from '../../visualization_container'; import { EmptyPlaceholder } from '@kbn/charts-plugin/public'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { DataContext, DatatableComponent } from './table_basic'; -import { LensMultiTable } from '../../../common'; import { DatatableProps } from '../../../common/expressions'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { IUiSettingsClient } from '@kbn/core/public'; -import { RenderMode } from '@kbn/expressions-plugin'; +import { Datatable, RenderMode } from '@kbn/expressions-plugin'; import { LENS_EDIT_PAGESIZE_ACTION } from './constants'; function sampleArgs() { const indexPatternId = 'indexPatternId'; - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - { - id: 'a', - name: 'a', - meta: { - type: 'string', - source: 'esaggs', - field: 'a', - sourceParams: { type: 'terms', indexPatternId }, - }, - }, - { - id: 'b', - name: 'b', - meta: { - type: 'date', - field: 'b', - source: 'esaggs', - sourceParams: { - type: 'date_histogram', - indexPatternId, - }, - }, - }, - { - id: 'c', - name: 'c', - meta: { - type: 'number', - source: 'esaggs', - field: 'c', - sourceParams: { indexPatternId, type: 'count' }, - }, + const data: Datatable = { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'string', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'terms', indexPatternId }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'date', + field: 'b', + source: 'esaggs', + sourceParams: { + type: 'date_histogram', + indexPatternId, }, - ], - rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], + }, }, - }, + { + id: 'c', + name: 'c', + meta: { + type: 'number', + source: 'esaggs', + field: 'c', + sourceParams: { indexPatternId, type: 'count' }, + }, + }, + ], + rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], }; const args: DatatableProps['args'] = { @@ -175,13 +169,7 @@ describe('DatatableComponent', () => { const wrapper = mountWithIntl( ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -206,7 +194,7 @@ describe('DatatableComponent', () => { { column: 0, row: 0, - table: data.tables.l1, + table: data, value: 'shoes', }, ], @@ -220,13 +208,7 @@ describe('DatatableComponent', () => { const wrapper = mountWithIntl( ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -251,7 +233,7 @@ describe('DatatableComponent', () => { { column: 1, row: 0, - table: data.tables.l1, + table: data, value: 1588024800000, }, ], @@ -261,35 +243,30 @@ describe('DatatableComponent', () => { }); test('it invokes executeTriggerActions with correct context on click on timefield from range', async () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - { - id: 'a', - name: 'a', - meta: { - type: 'date', - source: 'esaggs', - field: 'a', - sourceParams: { type: 'date_range', indexPatternId: 'a' }, - }, - }, - { - id: 'b', - name: 'b', - meta: { - type: 'number', - source: 'esaggs', - sourceParams: { type: 'count', indexPatternId: 'a' }, - }, - }, - ], - rows: [{ a: 1588024800000, b: 3 }], + const data: Datatable = { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'date', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'date_range', indexPatternId: 'a' }, + }, }, - }, + { + id: 'b', + name: 'b', + meta: { + type: 'number', + source: 'esaggs', + sourceParams: { type: 'count', indexPatternId: 'a' }, + }, + }, + ], + rows: [{ a: 1588024800000, b: 3 }], }; const args: DatatableProps['args'] = { @@ -305,13 +282,7 @@ describe('DatatableComponent', () => { const wrapper = mountWithIntl( ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -336,7 +307,7 @@ describe('DatatableComponent', () => { { column: 0, row: 0, - table: data.tables.l1, + table: data, value: 1588024800000, }, ], @@ -350,13 +321,7 @@ describe('DatatableComponent', () => { const wrapper = mountWithIntl( ({ convert: (x) => x } as IFieldFormat)} dispatchEvent={onDispatchEvent} @@ -377,14 +342,9 @@ describe('DatatableComponent', () => { test('it shows emptyPlaceholder for undefined bucketed data', () => { const { args, data } = sampleArgs(); - const emptyData: LensMultiTable = { + const emptyData: Datatable = { ...data, - tables: { - l1: { - ...data.tables.l1, - rows: [{ a: undefined, b: undefined, c: 0 }], - }, - }, + rows: [{ a: undefined, b: undefined, c: 0 }], }; const component = shallow( @@ -551,8 +511,7 @@ describe('DatatableComponent', () => { test('it detect last_value filtered metric type', () => { const { data, args } = sampleArgs(); - const table = data.tables.l1; - const column = table.columns[1]; + const column = data.columns[1]; column.meta = { ...column.meta, @@ -560,7 +519,7 @@ describe('DatatableComponent', () => { type: 'number', sourceParams: { ...column.meta.sourceParams, type: 'filtered_metric' }, }; - table.rows[0].b = 'Hello'; + data.rows[0].b = 'Hello'; const wrapper = shallow( { ); // mnake a copy of the data, changing only the name of the first column const newData = copyData(data); - newData.tables.l1.columns[0].name = 'new a'; + newData.columns[0].name = 'new a'; wrapper.setProps({ data: newData }); wrapper.update(); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index 76d677f74fe918..cf6cd1c635ceef 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -20,7 +20,8 @@ import { EuiDataGridStyle, } from '@elastic/eui'; import { EmptyPlaceholder } from '@kbn/charts-plugin/public'; -import type { LensFilterEvent, LensTableRowContextMenuEvent } from '../../types'; +import { ClickTriggerEvent } from '@kbn/charts-plugin/public'; +import type { LensTableRowContextMenuEvent } from '../../types'; import type { FormatFactory } from '../../../common'; import type { LensGridDirection } from '../../../common/expressions'; import { VisualizationContainer } from '../../visualization_container'; @@ -58,8 +59,6 @@ const PAGE_SIZE_OPTIONS = [DEFAULT_PAGE_SIZE, 20, 30, 50, 100]; export const DatatableComponent = (props: DatatableRenderProps) => { const dataGridRef = useRef(null); - const [firstTable] = Object.values(props.data.tables); - const isInteractive = props.interactive; const [columnConfig, setColumnConfig] = useState({ @@ -67,7 +66,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { sortingColumnId: props.args.sortingColumnId, sortingDirection: props.args.sortingDirection, }); - const [firstLocalTable, updateTable] = useState(firstTable); + const [firstLocalTable, updateTable] = useState(props.data); // ** Pagination config const [pagination, setPagination] = useState<{ pageIndex: number; pageSize: number } | undefined>( @@ -94,8 +93,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => { }, [props.args.columns, props.args.sortingColumnId, props.args.sortingDirection]); useDeepCompareEffect(() => { - updateTable(firstTable); - }, [firstTable]); + updateTable(props.data); + }, [props.data]); const firstTableRef = useRef(firstLocalTable); firstTableRef.current = firstLocalTable; @@ -120,7 +119,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ); const onClickValue = useCallback( - (data: LensFilterEvent['data']) => { + (data: ClickTriggerEvent['data']) => { dispatchEvent({ name: 'filter', data }); }, [dispatchEvent] @@ -193,7 +192,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { const isEmpty = firstLocalTable.rows.length === 0 || (bucketColumns.length && - firstTable.rows.every((row) => bucketColumns.every((col) => row[col] == null))); + props.data.rows.every((row) => bucketColumns.every((col) => row[col] == null))); const visibleColumns = useMemo( () => @@ -252,10 +251,10 @@ export const DatatableComponent = (props: DatatableRenderProps) => { columnConfig.columns .filter(({ columnId }) => isNumericMap[columnId]) .map(({ columnId }) => columnId), - firstTable, + props.data, getOriginalId ); - }, [firstTable, isNumericMap, columnConfig]); + }, [props.data, isNumericMap, columnConfig]); const headerRowHeight = props.args.headerRowHeight ?? 'single'; const headerRowLines = props.args.headerRowHeightLines ?? 1; @@ -375,7 +374,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { .map((config) => ({ columnId: config.columnId, summaryRowValue: config.summaryRowValue, - ...getFinalSummaryConfiguration(config.columnId, config, firstTable), + ...getFinalSummaryConfiguration(config.columnId, config, props.data), })) .filter(({ summaryRow }) => summaryRow !== 'none'); @@ -401,7 +400,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ) : null; }; } - }, [columnConfig.columns, alignments, firstTable, columns]); + }, [columnConfig.columns, alignments, props.data, columns]); if (isEmpty) { return ( diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx index 8a899780515b2c..3e798c58130415 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -6,56 +6,51 @@ */ import type { DatatableProps } from '../../common/expressions'; -import type { LensMultiTable } from '../../common'; import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; import type { FormatFactory } from '../../common'; import { getDatatable } from '../../common/expressions'; +import { Datatable } from '@kbn/expressions-plugin'; function sampleArgs() { const indexPatternId = 'indexPatternId'; - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - { - id: 'a', - name: 'a', - meta: { - type: 'string', - source: 'esaggs', - field: 'a', - sourceParams: { type: 'terms', indexPatternId }, - }, - }, - { - id: 'b', - name: 'b', - meta: { - type: 'date', - field: 'b', - source: 'esaggs', - sourceParams: { - type: 'date_histogram', - indexPatternId, - }, - }, - }, - { - id: 'c', - name: 'c', - meta: { - type: 'number', - source: 'esaggs', - field: 'c', - sourceParams: { indexPatternId, type: 'count' }, - }, + const data: Datatable = { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'string', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'terms', indexPatternId }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'date', + field: 'b', + source: 'esaggs', + sourceParams: { + type: 'date_histogram', + indexPatternId, }, - ], - rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], + }, + }, + { + id: 'c', + name: 'c', + meta: { + type: 'number', + source: 'esaggs', + field: 'c', + sourceParams: { indexPatternId, type: 'count' }, + }, }, - }, + ], + rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], }; const args: DatatableProps['args'] = { diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 1053eadce13630..3ae80571141981 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -46,16 +46,15 @@ export const getDatatableRenderer = (dependencies: { // ROW_CLICK_TRIGGER trigger. let rowHasRowClickTriggerActions: boolean[] = []; if (hasCompatibleActions) { - const table = Object.values(config.data.tables)[0]; - if (!!table) { + if (!!config.data) { rowHasRowClickTriggerActions = await Promise.all( - table.rows.map(async (row, rowIndex) => { + config.data.rows.map(async (row, rowIndex) => { try { const hasActions = await hasCompatibleActions({ name: 'tableRowContextMenuClick', data: { rowIndex, - table, + table: config.data, columns: config.args.columns.map((column) => column.columnId), }, }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 8087f43b90e725..4cc44a1b702932 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -326,7 +326,12 @@ export const getDatatableVisualization = ({ } }, - toExpression(state, datasourceLayers, { title, description } = {}): Ast | null { + toExpression( + state, + datasourceLayers, + { title, description } = {}, + datasourceExpressionsByLayers = {} + ): Ast | null { const { sortedColumns, datasource } = getDataSourceAndSortedColumns(state, datasourceLayers, state.layerId) || {}; @@ -346,9 +351,12 @@ export const getDatatableVisualization = ({ .filter((columnId) => datasource!.getOperationForColumnId(columnId)) .map((columnId) => columnMap[columnId]); + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; + return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: 'lens_datatable', diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index 91ca494866f30b..796128df989b48 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -32,7 +32,6 @@ import { EditorFrame, EditorFrameProps } from './editor_frame'; import { DatasourcePublicAPI, DatasourceSuggestion, Visualization } from '../../types'; import { act } from 'react-dom/test-utils'; import { coreMock } from '@kbn/core/public/mocks'; -import { fromExpression } from '@kbn/interpreter'; import { createMockVisualization, createMockDatasource, @@ -49,6 +48,7 @@ import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks'; import { mockDataPlugin, mountWithProvider } from '../../mocks'; import { setState } from '../../state_management'; import { getLensInspectorService } from '../../lens_inspector_service'; +import { toExpression } from '@kbn/interpreter'; function generateSuggestion(state = {}): DatasourceSuggestion { return { @@ -209,10 +209,20 @@ describe('editor_frame', () => { it('should render the resulting expression using the expression renderer', async () => { mockDatasource.getLayers.mockReturnValue(['first']); - const props = { + const props: EditorFrameProps = { ...getDefaultProps(), visualizationMap: { - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpression({ + type: 'expression', + chain: [ + ...(datasourceExpressionsByLayers.first?.chain ?? []), + { type: 'function', function: 'testVis', arguments: {} }, + ], + }), + }, }, datasourceMap: { testDatasource: { @@ -242,137 +252,10 @@ describe('editor_frame', () => { instance.update(); expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} + "datasource | testVis" `); }); - - it('should render individual expression for each given layer', async () => { - mockDatasource.toExpression.mockReturnValue('datasource'); - mockDatasource2.toExpression.mockImplementation((_state, layerId) => `datasource_${layerId}`); - mockDatasource.initialize.mockImplementation((initialState) => Promise.resolve(initialState)); - mockDatasource.getLayers.mockReturnValue(['first', 'second']); - mockDatasource2.initialize.mockImplementation((initialState) => - Promise.resolve(initialState) - ); - mockDatasource2.getLayers.mockReturnValue(['third']); - - const props = { - ...getDefaultProps(), - visualizationMap: { - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, - }, - datasourceMap: { - testDatasource: { - ...mockDatasource, - toExpression: () => 'datasource', - }, - testDatasource2: { - ...mockDatasource2, - toExpression: () => 'datasource_second', - }, - }, - - ExpressionRenderer: expressionRendererMock, - }; - - instance = ( - await mountWithProvider(, { - preloadedState: { - visualization: { activeId: 'testVis', state: {} }, - datasourceStates: { - testDatasource: { - isLoading: false, - state: { - internalState1: '', - }, - }, - testDatasource2: { - isLoading: false, - state: { - internalState1: '', - }, - }, - }, - }, - }) - ).instance; - - instance.update(); - - expect( - fromExpression(instance.find(expressionRendererMock).prop('expression') as string) - ).toEqual({ - type: 'expression', - chain: expect.arrayContaining([ - expect.objectContaining({ - arguments: expect.objectContaining({ layerIds: ['first', 'second', 'third'] }), - }), - ]), - }); - expect(fromExpression(instance.find(expressionRendererMock).prop('expression') as string)) - .toMatchInlineSnapshot(` - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "kibana", - "type": "function", - }, - Object { - "arguments": Object { - "layerIds": Array [ - "first", - "second", - "third", - ], - "tables": Array [ - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource", - "type": "function", - }, - ], - "type": "expression", - }, - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource", - "type": "function", - }, - ], - "type": "expression", - }, - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource_second", - "type": "function", - }, - ], - "type": "expression", - }, - ], - }, - "function": "lens_merge_tables", - "type": "function", - }, - Object { - "arguments": Object {}, - "function": "testVis", - "type": "function", - }, - ], - "type": "expression", - } - `); - }); }); describe('state update', () => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts index b5fa32cd8e306d..367d156929714a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { Ast, AstFunction, fromExpression } from '@kbn/interpreter'; +import { Ast, fromExpression } from '@kbn/interpreter'; import { DatasourceStates } from '../../state_management'; import { Visualization, DatasourceMap, DatasourceLayers } from '../../types'; @@ -16,8 +16,12 @@ export function getDatasourceExpressionsByLayers( const datasourceExpressions: Array<[string, Ast | string]> = []; Object.entries(datasourceMap).forEach(([datasourceId, datasource]) => { - const state = datasourceStates[datasourceId].state; - const layers = datasource.getLayers(datasourceStates[datasourceId].state); + const state = datasourceStates[datasourceId]?.state; + if (!state) { + return; + } + + const layers = datasource.getLayers(state); layers.forEach((layerId) => { const result = datasource.toExpression(state, layerId); @@ -40,46 +44,6 @@ export function getDatasourceExpressionsByLayers( ); } -export function prependDatasourceExpression( - visualizationExpression: Ast | string | null, - datasourceMap: DatasourceMap, - datasourceStates: DatasourceStates -): Ast | null { - const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( - datasourceMap, - datasourceStates - ); - - if (datasourceExpressionsByLayers === null || visualizationExpression === null) { - return null; - } - - const parsedDatasourceExpressions = Object.entries(datasourceExpressionsByLayers); - - const datafetchExpression: AstFunction = { - type: 'function', - function: 'lens_merge_tables', - arguments: { - layerIds: parsedDatasourceExpressions.map(([id]) => id), - tables: parsedDatasourceExpressions.map(([, expr]) => expr), - }, - }; - - const parsedVisualizationExpression = - typeof visualizationExpression === 'string' - ? fromExpression(visualizationExpression) - : visualizationExpression; - - return { - type: 'expression', - chain: [ - { type: 'function', function: 'kibana', arguments: {} }, - datafetchExpression, - ...parsedVisualizationExpression.chain, - ], - }; -} - export function buildExpression({ visualization, visualizationState, @@ -101,31 +65,26 @@ export function buildExpression({ return null; } - if (visualization.shouldBuildDatasourceExpressionManually?.()) { - const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( - datasourceMap, - datasourceStates - ); + const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( + datasourceMap, + datasourceStates + ); - const visualizationExpression = visualization.toExpression( - visualizationState, - datasourceLayers, - { - title, - description, - }, - datasourceExpressionsByLayers ?? undefined - ); + const visualizationExpression = visualization.toExpression( + visualizationState, + datasourceLayers, + { + title, + description, + }, + datasourceExpressionsByLayers ?? undefined + ); - return typeof visualizationExpression === 'string' - ? fromExpression(visualizationExpression) - : visualizationExpression; + if (datasourceExpressionsByLayers === null || visualizationExpression === null) { + return null; } - const visualizationExpression = visualization.toExpression(visualizationState, datasourceLayers, { - title, - description, - }); - - return prependDatasourceExpression(visualizationExpression, datasourceMap, datasourceStates); + return typeof visualizationExpression === 'string' + ? fromExpression(visualizationExpression) + : visualizationExpression; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index cda496aa693e89..7eff9a5961e83c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -386,9 +386,7 @@ describe('suggestion_panel', () => { const passedExpression = (expressionRendererMock as jest.Mock).mock.calls[0][0].expression; expect(passedExpression).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource_expression} - | test + "test | expression" `); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index dc36e0a671cf0f..abd6da25c52ea2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -39,10 +39,7 @@ import { DatasourceLayers, } from '../../types'; import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; -import { - getDatasourceExpressionsByLayers, - prependDatasourceExpression, -} from './expression_helpers'; +import { getDatasourceExpressionsByLayers } from './expression_helpers'; import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry'; import { getMissingIndexPattern, @@ -522,22 +519,15 @@ function getPreviewExpression( }); } - if (visualization.shouldBuildDatasourceExpressionManually?.()) { - const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( - datasources, - datasourceStates - ); - - return visualization.toPreviewExpression( - visualizableState.visualizationState, - suggestionFrameApi.datasourceLayers, - datasourceExpressionsByLayers ?? undefined - ); - } + const datasourceExpressionsByLayers = getDatasourceExpressionsByLayers( + datasources, + datasourceStates + ); return visualization.toPreviewExpression( visualizableState.visualizationState, - suggestionFrameApi.datasourceLayers + suggestionFrameApi.datasourceLayers, + datasourceExpressionsByLayers ?? undefined ); } @@ -561,15 +551,11 @@ function preparePreviewExpression( } : datasourceStates; - const previewExprDatasourcesStates = visualization.shouldBuildDatasourceExpressionManually?.() - ? datasourceStatesWithSuggestions - : datasourceStates; - const expression = getPreviewExpression( visualizableState, visualization, datasourceMap, - previewExprDatasourcesStates, + datasourceStatesWithSuggestions, framePublicAPI ); @@ -577,9 +563,5 @@ function preparePreviewExpression( return; } - if (visualization.shouldBuildDatasourceExpressionManually?.()) { - return typeof expression === 'string' ? fromExpression(expression) : expression; - } - - return prependDatasourceExpression(expression, datasourceMap, datasourceStatesWithSuggestions); + return typeof expression === 'string' ? fromExpression(expression) : expression; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index 6e546459a70118..d12d4beb02f2c3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -26,7 +26,6 @@ jest.mock('../../../debounced_component', () => { import { WorkspacePanel } from './workspace_panel'; import { ReactWrapper } from 'enzyme'; import { DragDrop, ChildDragDropProvider } from '../../../drag_drop'; -import { fromExpression } from '@kbn/interpreter'; import { buildExistsFilter } from '@kbn/es-query'; import { coreMock } from '@kbn/core/public/mocks'; import { DataView } from '@kbn/data-views-plugin/public'; @@ -44,6 +43,7 @@ import { import { getLensInspectorService } from '../../../lens_inspector_service'; import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks'; import { disableAutoApply, enableAutoApply } from '../../../state_management/lens_slice'; +import { Ast, toExpression } from '@kbn/interpreter'; const defaultPermissions: Record>> = { navLinks: { management: true }, @@ -73,6 +73,19 @@ const defaultProps = { toggleFullscreen: jest.fn(), }; +const toExpr = ( + datasourceExpressionsByLayers: Record, + fn: string = 'testVis', + layerId: string = 'first' +) => + toExpression({ + type: 'expression', + chain: [ + ...(datasourceExpressionsByLayers[layerId]?.chain ?? []), + { type: 'function', function: fn, arguments: {} }, + ], + }); + const SELECTORS = { applyChangesButton: 'button[data-test-subj="lnsApplyChanges__toolbar"]', dragDropPrompt: '[data-test-subj="workspace-drag-drop-prompt"]', @@ -148,7 +161,11 @@ describe('workspace_panel', () => { 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers), + }, }} />, @@ -177,7 +194,11 @@ describe('workspace_panel', () => { }} framePublicAPI={framePublicAPI} visualizationMap={{ - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers), + }, }} ExpressionRenderer={expressionRendererMock} /> @@ -188,8 +209,7 @@ describe('workspace_panel', () => { instance.update(); expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} + "datasource | testVis" `); }); @@ -210,7 +230,11 @@ describe('workspace_panel', () => { }} framePublicAPI={framePublicAPI} visualizationMap={{ - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers), + }, }} ExpressionRenderer={expressionRendererMock} />, @@ -228,26 +252,28 @@ describe('workspace_panel', () => { // allows initial render expect(getExpression()).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} - | testVis" - `); + "datasource + | testVis" + `); mockDatasource.toExpression.mockReturnValue('new-datasource'); act(() => { instance.setProps({ visualizationMap: { - testVis: { ...mockVisualization, toExpression: () => 'new-vis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers, 'new-vis'), + } as Visualization, }, }); }); instance.update(); expect(getExpression()).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} - | testVis" - `); + "datasource + | testVis" + `); act(() => { mounted.lensStore.dispatch(applyChanges()); @@ -256,16 +282,19 @@ describe('workspace_panel', () => { // should update expect(getExpression()).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={new-datasource} - | new-vis" - `); + "new-datasource + | new-vis" + `); mockDatasource.toExpression.mockReturnValue('other-new-datasource'); act(() => { instance.setProps({ visualizationMap: { - testVis: { ...mockVisualization, toExpression: () => 'other-new-vis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers, 'other-new-vis'), + } as Visualization, }, }); mounted.lensStore.dispatch(enableAutoApply()); @@ -274,10 +303,9 @@ describe('workspace_panel', () => { // reenabling auto-apply triggers an update as well expect(getExpression()).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={other-new-datasource} - | other-new-vis" - `); + "other-new-datasource + | other-new-vis" + `); }); it('should base saveability on working changes when auto-apply disabled', async () => { @@ -305,7 +333,11 @@ describe('workspace_panel', () => { }} framePublicAPI={framePublicAPI} visualizationMap={{ - testVis: { ...mockVisualization, toExpression: () => 'testVis' }, + testVis: { + ...mockVisualization, + toExpression: (state, datasourceLayers, attrs, datasourceExpressionsByLayers = {}) => + toExpr(datasourceExpressionsByLayers), + }, }} ExpressionRenderer={expressionRendererMock} /> @@ -318,10 +350,9 @@ describe('workspace_panel', () => { // allows initial render expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(` - "kibana - | lens_merge_tables layerIds=\\"first\\" tables={datasource} - | testVis" - `); + "datasource + | testVis" + `); expect(isSaveable()).toBe(true); act(() => { @@ -499,90 +530,6 @@ describe('workspace_panel', () => { }); }); - it('should include data fetching for each layer in the expression', async () => { - const mockDatasource2 = createMockDatasource('a'); - const framePublicAPI = createMockFramePublicAPI(); - framePublicAPI.datasourceLayers = { - first: mockDatasource.publicAPIMock, - second: mockDatasource2.publicAPIMock, - }; - mockDatasource.toExpression.mockReturnValue('datasource'); - mockDatasource.getLayers.mockReturnValue(['first']); - - mockDatasource2.toExpression.mockReturnValue('datasource2'); - mockDatasource2.getLayers.mockReturnValue(['second', 'third']); - - const mounted = await mountWithProvider( - 'testVis' }, - }} - ExpressionRenderer={expressionRendererMock} - />, - - { - preloadedState: { - datasourceStates: { - testDatasource: { - state: {}, - isLoading: false, - }, - mock2: { - state: {}, - isLoading: false, - }, - }, - }, - } - ); - instance = mounted.instance; - instance.update(); - - const ast = fromExpression(instance.find(expressionRendererMock).prop('expression') as string); - - expect(ast.chain[1].arguments.layerIds).toEqual(['first', 'second', 'third']); - expect(ast.chain[1].arguments.tables).toMatchInlineSnapshot(` - Array [ - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource", - "type": "function", - }, - ], - "type": "expression", - }, - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource2", - "type": "function", - }, - ], - "type": "expression", - }, - Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "datasource2", - "type": "function", - }, - ], - "type": "expression", - }, - ] - `); - }); - it('should run the expression again if the date range changes', async () => { const framePublicAPI = createMockFramePublicAPI(); framePublicAPI.datasourceLayers = { diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 56a24458bbdc02..d706b7d484c63c 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -51,6 +51,7 @@ import type { ThemeServiceStart, } from '@kbn/core/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import { BrushTriggerEvent, ClickTriggerEvent } from '@kbn/charts-plugin/public'; import { Document } from '../persistence'; import { ExpressionWrapper, ExpressionWrapperProps } from './expression_wrapper'; import { @@ -58,8 +59,6 @@ import { isLensFilterEvent, isLensEditEvent, isLensTableRowContextMenuClickEvent, - LensBrushEvent, - LensFilterEvent, LensTableRowContextMenuEvent, VisualizationMap, Visualization, @@ -94,9 +93,9 @@ interface LensBaseEmbeddableInput extends EmbeddableInput { renderMode?: RenderMode; style?: React.CSSProperties; className?: string; - onBrushEnd?: (data: LensBrushEvent['data']) => void; + onBrushEnd?: (data: BrushTriggerEvent['data']) => void; onLoad?: (isLoading: boolean) => void; - onFilter?: (data: LensFilterEvent['data']) => void; + onFilter?: (data: ClickTriggerEvent['data']) => void; onTableRowClick?: (data: LensTableRowContextMenuEvent['data']) => void; } diff --git a/x-pack/plugins/lens/public/expressions.ts b/x-pack/plugins/lens/public/expressions.ts index ba8c07078a2085..a52d835e3f0023 100644 --- a/x-pack/plugins/lens/public/expressions.ts +++ b/x-pack/plugins/lens/public/expressions.ts @@ -8,22 +8,17 @@ import type { ExpressionsSetup } from '@kbn/expressions-plugin/public'; import { getDatatable } from '../common/expressions/datatable/datatable'; import { datatableColumn } from '../common/expressions/datatable/datatable_column'; -import { mergeTables } from '../common/expressions/merge_tables'; import { renameColumns } from '../common/expressions/rename_columns/rename_columns'; import { formatColumn } from '../common/expressions/format_column'; import { counterRate } from '../common/expressions/counter_rate'; import { getTimeScale } from '../common/expressions/time_scale/time_scale'; -import { lensMultitable } from '../common/expressions'; export const setupExpressions = ( expressions: ExpressionsSetup, formatFactory: Parameters[0], getTimeZone: Parameters[0] ) => { - [lensMultitable].forEach((expressionType) => expressions.registerType(expressionType)); - [ - mergeTables, counterRate, formatColumn, renameColumns, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index f0a8e65889e8e2..46c86d8c0adb0a 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -295,8 +295,14 @@ export const getHeatmapVisualization = ({ } }, - toExpression(state, datasourceLayers, attributes): Ast | null { + toExpression( + state, + datasourceLayers, + attributes, + datasourceExpressionsByLayers = {} + ): Ast | null { const datasource = datasourceLayers[state.layerId]; + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); // When we add a column it could be empty, and therefore have no order @@ -308,6 +314,7 @@ export const getHeatmapVisualization = ({ return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: FUNCTION_NAME, @@ -383,8 +390,9 @@ export const getHeatmapVisualization = ({ }; }, - toPreviewExpression(state, datasourceLayers): Ast | null { + toPreviewExpression(state, datasourceLayers, datasourceExpressionsByLayers = {}): Ast | null { const datasource = datasourceLayers[state.layerId]; + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); // When we add a column it could be empty, and therefore have no order @@ -396,6 +404,7 @@ export const getHeatmapVisualization = ({ return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: FUNCTION_NAME, diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index b8d00e7ff61b8b..edf57ba703a2e5 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -87,7 +87,6 @@ export type { IconPosition, ExtendedYConfigResult, DataLayerArgs, - LensMultiTable, ValueLabelMode, AxisExtentMode, DataLayerConfig, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts index db10c420b90de2..6806b1ce47795e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -383,6 +383,11 @@ describe('IndexPattern Data Source', () => { expect(indexPatternDatasource.toExpression(state, 'first')).toMatchInlineSnapshot(` Object { "chain": Array [ + Object { + "arguments": Object {}, + "function": "kibana", + "type": "function", + }, Object { "arguments": Object { "aggs": Array [ @@ -552,7 +557,7 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); + expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); }); it('should pass time shift parameter to metric agg functions', async () => { @@ -589,7 +594,7 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect((ast.chain[0].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']); + expect((ast.chain[1].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']); }); it('should wrap filtered metrics in filtered metric aggregation', async () => { @@ -638,7 +643,7 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(ast.chain[0].arguments.aggs[0]).toMatchInlineSnapshot(` + expect(ast.chain[1].arguments.aggs[0]).toMatchInlineSnapshot(` Object { "chain": Array [ Object { @@ -898,8 +903,8 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(ast.chain[0].arguments.metricsAtAllLevels).toEqual([false]); - expect(JSON.parse(ast.chain[1].arguments.idMap[0] as string)).toEqual({ + expect(ast.chain[1].arguments.metricsAtAllLevels).toEqual([false]); + expect(JSON.parse(ast.chain[2].arguments.idMap[0] as string)).toEqual({ 'col-0-0': expect.objectContaining({ id: 'bucket1' }), 'col-1-1': expect.objectContaining({ id: 'bucket2' }), 'col-2-2': expect.objectContaining({ id: 'metric' }), @@ -939,8 +944,8 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(ast.chain[0].arguments.timeFields).toEqual(['timestamp']); - expect(ast.chain[0].arguments.timeFields).not.toContain('timefield'); + expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp']); + expect(ast.chain[1].arguments.timeFields).not.toContain('timefield'); }); describe('references', () => { @@ -988,7 +993,7 @@ describe('IndexPattern Data Source', () => { const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; // @ts-expect-error we can't isolate just the reference type expect(operationDefinitionMap.testReference.toExpression).toHaveBeenCalled(); - expect(ast.chain[2]).toEqual('mock'); + expect(ast.chain[3]).toEqual('mock'); }); it('should keep correct column mapping keys with reference columns present', async () => { @@ -1021,7 +1026,7 @@ describe('IndexPattern Data Source', () => { const state = enrichBaseState(queryBaseState); const ast = indexPatternDatasource.toExpression(state, 'first') as Ast; - expect(JSON.parse(ast.chain[1].arguments.idMap[0] as string)).toEqual({ + expect(JSON.parse(ast.chain[2].arguments.idMap[0] as string)).toEqual({ 'col-0-0': expect.objectContaining({ id: 'col1', }), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index aee1abd34e7cbe..0307e748ac1fb9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -277,6 +277,7 @@ function getExpressionForLayer( return { type: 'expression', chain: [ + { type: 'function', function: 'kibana', arguments: {} }, buildExpressionFunction('esaggs', { index: buildExpression([ buildExpressionFunction( diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index 62c607f69265e5..786d5b588baef5 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -15,6 +15,7 @@ import { PaletteOutput, PaletteRegistry, CUSTOM_PALETTE, shiftPalette } from '@k import { ThemeServiceStart } from '@kbn/core/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { ColorMode, CustomPaletteState } from '@kbn/charts-plugin/common'; +import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public'; import { getSuggestions } from './metric_suggestions'; import { LensIconChartMetric } from '../assets/chart_metric'; import { Visualization, OperationMetadata, DatasourceLayers } from '../types'; @@ -49,13 +50,15 @@ const toExpression = ( paletteService: PaletteRegistry, state: MetricState, datasourceLayers: DatasourceLayers, - attributes?: Partial> + attributes?: Partial>, + datasourceExpressionsByLayers: Record | undefined = {} ): Ast | null => { if (!state.accessor) { return null; } const [datasource] = Object.values(datasourceLayers); + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; const operation = datasource && datasource.getOperationForColumnId(state.accessor); const stops = state.palette?.params?.stops || []; @@ -99,6 +102,7 @@ const toExpression = ( return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: 'metricVis', @@ -225,6 +229,7 @@ export const getMetricVisualization = ({ } ); }, + triggers: [VIS_EVENT_TO_TRIGGER.filter], getConfiguration(props) { const hasColoring = props.state.palette != null; @@ -271,10 +276,23 @@ export const getMetricVisualization = ({ } }, - toExpression: (state, datasourceLayers, attributes) => - toExpression(paletteService, state, datasourceLayers, { ...attributes }), - toPreviewExpression: (state, datasourceLayers) => - toExpression(paletteService, state, datasourceLayers, { mode: 'reduced' }), + toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers) => + toExpression( + paletteService, + state, + datasourceLayers, + { ...attributes }, + datasourceExpressionsByLayers + ), + + toPreviewExpression: (state, datasourceLayers, datasourceExpressionsByLayers) => + toExpression( + paletteService, + state, + datasourceLayers, + { mode: 'reduced' }, + datasourceExpressionsByLayers + ), setDimension({ prevState, columnId }) { return { ...prevState, accessor: columnId }; diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index 73ddeea6279672..574f61bfc9232a 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -247,7 +247,8 @@ function expressionHelper( state: PieVisualizationState, datasourceLayers: DatasourceLayers, paletteService: PaletteRegistry, - attributes: Attributes = { isPreview: false } + attributes: Attributes = { isPreview: false }, + datasourceExpressionsByLayers: Record ): Ast | null { const layer = state.layers[0]; const datasource = datasourceLayers[layer.layerId]; @@ -263,26 +264,55 @@ function expressionHelper( if (!layer.metric || !operations.length) { return null; } + const visualizationAst = generateExprAst( + state, + attributes, + operations, + layer, + datasourceLayers, + paletteService + ); - return generateExprAst(state, attributes, operations, layer, datasourceLayers, paletteService); + const datasourceAst = datasourceExpressionsByLayers[layer.layerId]; + return { + type: 'expression', + chain: [ + ...(datasourceAst ? datasourceAst.chain : []), + ...(visualizationAst ? visualizationAst.chain : []), + ], + }; } export function toExpression( state: PieVisualizationState, datasourceLayers: DatasourceLayers, paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} + attributes: Partial<{ title: string; description: string }> = {}, + datasourceExpressionsByLayers: Record | undefined = {} ) { - return expressionHelper(state, datasourceLayers, paletteService, { - ...attributes, - isPreview: false, - }); + return expressionHelper( + state, + datasourceLayers, + paletteService, + { + ...attributes, + isPreview: false, + }, + datasourceExpressionsByLayers + ); } export function toPreviewExpression( state: PieVisualizationState, datasourceLayers: DatasourceLayers, - paletteService: PaletteRegistry + paletteService: PaletteRegistry, + datasourceExpressionsByLayers: Record | undefined = {} ) { - return expressionHelper(state, datasourceLayers, paletteService, { isPreview: true }); + return expressionHelper( + state, + datasourceLayers, + paletteService, + { isPreview: true }, + datasourceExpressionsByLayers + ); } diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 665fd5522c36fa..927c67dce68b7c 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -241,9 +241,11 @@ export const getPieVisualization = ({ return state?.layers.find(({ layerId: id }) => id === layerId)?.layerType; }, - toExpression: (state, layers, attributes) => - toExpression(state, layers, paletteService, attributes), - toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), + toExpression: (state, layers, attributes, datasourceExpressionsByLayers) => + toExpression(state, layers, paletteService, attributes, datasourceExpressionsByLayers), + + toPreviewExpression: (state, layers, datasourceExpressionsByLayers) => + toPreviewExpression(state, layers, paletteService, datasourceExpressionsByLayers), renderToolbar(domElement, props) { render( diff --git a/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx index fa7e12083435c4..889c7697b8f687 100644 --- a/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx +++ b/x-pack/plugins/lens/public/shared_components/legend_action_popover.tsx @@ -9,7 +9,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; import { useLegendAction } from '@elastic/charts'; -import type { LensFilterEvent } from '../types'; +import { ClickTriggerEvent } from '@kbn/charts-plugin/public'; export interface LegendActionPopoverProps { /** @@ -19,11 +19,11 @@ export interface LegendActionPopoverProps { /** * Callback on filter value */ - onFilter: (data: LensFilterEvent['data']) => void; + onFilter: (data: ClickTriggerEvent['data']) => void; /** * Determines the filter event data */ - context: LensFilterEvent['data']; + context: ClickTriggerEvent['data']; } export const LegendActionPopover: React.FunctionComponent = ({ diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a91240e7e6a3e8..1f2ee1266ddb74 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -23,12 +23,12 @@ import type { } from '@kbn/expressions-plugin/public'; import type { VisualizeEditorLayersContext } from '@kbn/visualizations-plugin/public'; import type { Query } from '@kbn/data-plugin/public'; -import type { RangeSelectContext, ValueClickContext } from '@kbn/embeddable-plugin/public'; import type { UiActionsStart, RowClickContext, VisualizeFieldContext, } from '@kbn/ui-actions-plugin/public'; +import { ClickTriggerEvent, BrushTriggerEvent } from '@kbn/charts-plugin/public'; import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; import type { DateRange, LayerType, SortingHint } from '../common'; import type { @@ -931,22 +931,6 @@ export interface Visualization { * On Edit events the frame will call this to know what's going to be the next visualization state */ onEditAction?: (state: T, event: LensEditEvent) => T; - - /** - * `datasourceExpressionsByLayers` will be passed to the params of `toExpression` and `toPreviewExpression` - * functions and datasource expressions will not be appended to the expression automatically. - */ - shouldBuildDatasourceExpressionManually?: () => boolean; -} - -export interface LensFilterEvent { - name: 'filter'; - data: ValueClickContext['data']; -} - -export interface LensBrushEvent { - name: 'brush'; - data: RangeSelectContext['data']; } // Use same technique as TriggerContext @@ -975,11 +959,11 @@ export interface LensTableRowContextMenuEvent { data: RowClickContext['data']; } -export function isLensFilterEvent(event: ExpressionRendererEvent): event is LensFilterEvent { +export function isLensFilterEvent(event: ExpressionRendererEvent): event is ClickTriggerEvent { return event.name === 'filter'; } -export function isLensBrushEvent(event: ExpressionRendererEvent): event is LensBrushEvent { +export function isLensBrushEvent(event: ExpressionRendererEvent): event is BrushTriggerEvent { return event.name === 'brush'; } @@ -991,7 +975,7 @@ export function isLensEditEvent( export function isLensTableRowContextMenuClickEvent( event: ExpressionRendererEvent -): event is LensBrushEvent { +): event is BrushTriggerEvent { return event.name === 'tableRowContextMenuClick'; } @@ -1003,8 +987,8 @@ export function isLensTableRowContextMenuClickEvent( export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandlers { event: ( event: - | LensFilterEvent - | LensBrushEvent + | ClickTriggerEvent + | BrushTriggerEvent | LensEditEvent | LensTableRowContextMenuEvent ) => void; diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index 2a2bd0a35efa19..0b650ccbedbc0c 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -12,15 +12,9 @@ import type { TimefilterContract } from '@kbn/data-plugin/public'; import type { IUiSettingsClient, SavedObjectReference } from '@kbn/core/public'; import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public'; import { search } from '@kbn/data-plugin/public'; +import { BrushTriggerEvent, ClickTriggerEvent } from '@kbn/charts-plugin/public'; import type { Document } from './persistence/saved_object_store'; -import type { - Datasource, - DatasourceMap, - LensBrushEvent, - LensFilterEvent, - Visualization, - StateSetter, -} from './types'; +import type { Datasource, DatasourceMap, Visualization, StateSetter } from './types'; import type { DatasourceStates, VisualizationState } from './state_management'; export function getVisualizeGeoFieldMessage(fieldType: string) { @@ -153,7 +147,7 @@ export function getRemoveOperation( return layerCount === 1 ? 'clear' : 'remove'; } -export function inferTimeField(context: LensBrushEvent['data'] | LensFilterEvent['data']) { +export function inferTimeField(context: BrushTriggerEvent['data'] | ClickTriggerEvent['data']) { const tablesAndColumns = 'table' in context ? [{ table: context.table, column: context.column }] diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index 74f7d518e11666..e85ef81a5dd8c4 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -113,9 +113,11 @@ const toExpression = ( paletteService: PaletteRegistry, state: GaugeVisualizationState, datasourceLayers: DatasourceLayers, - attributes?: Partial> + attributes?: Partial>, + datasourceExpressionsByLayers: Record | undefined = {} ): Ast | null => { const datasource = datasourceLayers[state.layerId]; + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); if (!originalOrder || !state.metricAccessor) { @@ -125,6 +127,7 @@ const toExpression = ( return { type: 'expression', chain: [ + ...(datasourceExpression?.chain ?? []), { type: 'function', function: EXPRESSION_GAUGE_NAME, @@ -420,10 +423,17 @@ export const getGaugeVisualization = ({ } }, - toExpression: (state, datasourceLayers, attributes) => - toExpression(paletteService, state, datasourceLayers, { ...attributes }), - toPreviewExpression: (state, datasourceLayers) => - toExpression(paletteService, state, datasourceLayers), + toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers = {}) => + toExpression( + paletteService, + state, + datasourceLayers, + { ...attributes }, + datasourceExpressionsByLayers + ), + + toPreviewExpression: (state, datasourceLayers, datasourceExpressionsByLayers = {}) => + toExpression(paletteService, state, datasourceLayers, undefined, datasourceExpressionsByLayers), getErrorMessages(state) { // not possible to break it? diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 9c4ee0d3b245f1..afdfd8e2001009 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -134,13 +134,7 @@ Object { ], "table": Array [ Object { - "chain": Array [ - Object { - "arguments": Object {}, - "function": "kibana", - "type": "function", - }, - ], + "chain": Array [], "type": "expression", }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts index a329c12b083a59..67febcd4b9d00e 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.test.ts @@ -6,9 +6,10 @@ */ import { getColorAssignments } from './color_assignment'; -import type { FormatFactory, LensMultiTable } from '../../common'; +import type { FormatFactory } from '../../common'; import { layerTypes } from '../../common'; import { XYDataLayerConfig } from './types'; +import { Datatable } from '@kbn/expressions-plugin'; describe('color_assignment', () => { const layers: XYDataLayerConfig[] = [ @@ -30,8 +31,7 @@ describe('color_assignment', () => { }, ]; - const data: LensMultiTable = { - type: 'lens_multitable', + const data: { tables: Record } = { tables: { '1': { type: 'datatable', 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 1acc53a9db5127..cf9a441fc6f533 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -12,7 +12,6 @@ import type { PaletteRegistry } from '@kbn/coloring'; import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public'; import { LegendSize } from '@kbn/visualizations-plugin/common/constants'; import type { AxisExtentConfig, YConfig, ExtendedYConfig } from '@kbn/expression-xy-plugin/common'; -import type { ExpressionAstExpression } from '@kbn/expressions-plugin/common'; import { State, XYDataLayerConfig, @@ -345,11 +344,6 @@ export const buildExpression = ( }; }; -const buildTableExpression = (datasourceExpression: Ast): ExpressionAstExpression => ({ - type: 'expression', - chain: [{ type: 'function', function: 'kibana', arguments: {} }, ...datasourceExpression.chain], -}); - const referenceLineLayerToExpression = ( layer: XYReferenceLineLayerConfig, datasourceLayer: DatasourcePublicAPI, @@ -370,7 +364,7 @@ const referenceLineLayerToExpression = ( : [], accessors: layer.accessors, columnToLabel: [JSON.stringify(getColumnToLabelMap(layer, datasourceLayer))], - ...(datasourceExpression ? { table: [buildTableExpression(datasourceExpression)] } : {}), + ...(datasourceExpression ? { table: [datasourceExpression] } : {}), }, }, ], @@ -439,7 +433,7 @@ const dataLayerToExpression = ( seriesType: [layer.seriesType], accessors: layer.accessors, columnToLabel: [JSON.stringify(columnToLabel)], - ...(datasourceExpression ? { table: [buildTableExpression(datasourceExpression)] } : {}), + ...(datasourceExpression ? { table: [datasourceExpression] } : {}), palette: [ { type: 'expression', diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 97bac36a934653..6c3fe3dcaea7fc 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -514,8 +514,6 @@ export const getXyVisualization = ({ ); }, - shouldBuildDatasourceExpressionManually: () => true, - toExpression: (state, layers, attributes, datasourceExpressionsByLayers = {}) => toExpression( state, diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts index b124a9cbaf33bc..d5bb5587b982ed 100644 --- a/x-pack/plugins/lens/server/expressions/expressions.ts +++ b/x-pack/plugins/lens/server/expressions/expressions.ts @@ -13,7 +13,6 @@ import { renameColumns, getTimeScale, getDatatable, - lensMultitable, } from '../../common/expressions'; import { getFormatFactory, getTimeZoneFactory } from './utils'; @@ -23,8 +22,6 @@ export const setupExpressions = ( core: CoreSetup, expressions: ExpressionsServerSetup ) => { - [lensMultitable].forEach((expressionType) => expressions.registerType(expressionType)); - [ counterRate, formatColumn, diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx index 5089a8cc6c8d5d..467c2ce92ff6e9 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx @@ -45,12 +45,10 @@ export function ChoroplethChart({ return null; } - const table = data.tables[args.layerId]; - let emsLayerId = args.emsLayerId ? args.emsLayerId : emsWorldLayerId; let emsField = args.emsField ? args.emsField : 'iso2'; if (!args.emsLayerId || !args.emsField) { - const emsSuggestion = getEmsSuggestion(emsFileLayers, table, args.regionAccessor); + const emsSuggestion = getEmsSuggestion(emsFileLayers, data, args.regionAccessor); if (emsSuggestion) { emsLayerId = emsSuggestion.layerId; emsField = emsSuggestion.field; @@ -66,7 +64,7 @@ export function ChoroplethChart({ defaultMessage: '{emsLayerLabel} by {accessorLabel}', values: { emsLayerLabel, - accessorLabel: getAccessorLabel(table, args.valueAccessor), + accessorLabel: getAccessorLabel(data, args.valueAccessor), }, }) : '', @@ -76,16 +74,16 @@ export function ChoroplethChart({ right: { id: args.valueAccessor, type: SOURCE_TYPES.TABLE_SOURCE, - __rows: table.rows, + __rows: data.rows, __columns: [ { name: args.regionAccessor, - label: getAccessorLabel(table, args.regionAccessor), + label: getAccessorLabel(data, args.regionAccessor), type: 'string', }, { name: args.valueAccessor, - label: getAccessorLabel(table, args.valueAccessor), + label: getAccessorLabel(data, args.valueAccessor), type: 'number', }, ], diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts index 7ed1ddfbd43817..989cc06c5d53be 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts @@ -6,8 +6,8 @@ */ import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common'; +import { Datatable } from '@kbn/expressions-plugin/common'; import { i18n } from '@kbn/i18n'; -import type { LensMultiTable } from '@kbn/lens-plugin/common'; import { prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; import type { ChoroplethChartConfig, ChoroplethChartProps } from './types'; import { RENDERER_ID } from './expression_renderer'; @@ -20,7 +20,7 @@ interface ChoroplethChartRender { export const getExpressionFunction = (): ExpressionFunctionDefinition< 'lens_choropleth_chart', - LensMultiTable, + Datatable, Omit, ChoroplethChartRender > => ({ @@ -57,11 +57,14 @@ export const getExpressionFunction = (): ExpressionFunctionDefinition< help: 'Value accessor identifies the value column', }, }, - inputTypes: ['lens_multitable'], + inputTypes: ['datatable'], fn(data, args, handlers) { if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( - Object.values(data.tables)[0], + data, [ [ args.valueAccessor ? [args.valueAccessor] : undefined, @@ -88,6 +91,6 @@ export const getExpressionFunction = (): ExpressionFunctionDefinition< data, args, }, - } as ChoroplethChartRender; + }; }, }); diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts index 79c05a93ef2d48..7dc9a16056e77d 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { LensMultiTable } from '@kbn/lens-plugin/common'; +import { Datatable } from '@kbn/expressions-plugin/common'; export interface ChoroplethChartState { layerId: string; @@ -21,6 +21,6 @@ export interface ChoroplethChartConfig extends ChoroplethChartState { } export interface ChoroplethChartProps { - data: LensMultiTable; + data: Datatable; args: ChoroplethChartConfig; } diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx index cbac26f220163d..54f459c3f7b389 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx @@ -138,14 +138,16 @@ export const getVisualization = ({ } }, - toExpression: (state, datasourceLayers, attributes) => { + toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers = {}) => { if (!state.regionAccessor || !state.valueAccessor) { return null; } + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; return { type: 'expression', chain: [ + ...(datasourceExpression ? datasourceExpression.chain : []), { type: 'function', function: 'lens_choropleth_chart', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js index fd00058e647130..13f7bb58a0f448 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js @@ -99,6 +99,14 @@ export function extractJobDetails(job, basePath, refreshJobList) { return ['', ]; }), }; + if (job.alerting_rules) { + // remove the alerting_rules list from the general section + // so not to show it twice. + const i = general.items.findIndex((item) => item[0] === 'alerting_rules'); + if (i >= 0) { + general.items.splice(i, 1); + } + } const detectors = { id: 'detectors', diff --git a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap index 4187db5b20641c..935f3e297b2cb9 100644 --- a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap +++ b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap @@ -188,42 +188,40 @@ Array [ `; exports[`stream handler showNotifications show success 1`] = ` -Array [ - Object { - "color": "success", - "data-test-subj": "completeReportSuccess", - "text": MountPoint { - "reactNode": -

- -

- +

+ - , - }, - "title": MountPoint { - "reactNode": + , - }, + /> + , }, -] + "title": MountPoint { + "reactNode": , + }, +} `; diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 6f575652450c1a..d3075d4e5a9069 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash'; import sinon, { stub } from 'sinon'; import { NotificationsStart } from '@kbn/core/public'; import { coreMock, themeServiceMock, docLinksServiceMock } from '@kbn/core/public/mocks'; @@ -123,7 +124,7 @@ describe('stream handler', () => { expect(mockShowDanger.callCount).toBe(0); expect(mockShowSuccess.callCount).toBe(1); expect(mockShowWarning.callCount).toBe(0); - expect(mockShowSuccess.args[0]).toMatchSnapshot(); + expect(omit(mockShowSuccess.args[0][0], 'toastLifeTimeMs')).toMatchSnapshot(); done(); }); }); diff --git a/x-pack/plugins/reporting/public/notifier/job_success.tsx b/x-pack/plugins/reporting/public/notifier/job_success.tsx index 44389e164472ac..f7b71d78de8bd2 100644 --- a/x-pack/plugins/reporting/public/notifier/job_success.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_success.tsx @@ -37,5 +37,12 @@ export const getSuccessToast = ( , { theme$: theme.theme$ } ), + /** + * If timeout is an Infinity value, a Not-a-Number (NaN) value, or negative, then timeout will be zero. + * And we cannot use `Number.MAX_SAFE_INTEGER` because EUI's Timer implementation + * subtracts it from the current time to evaluate the remainder. + * @see https://www.w3.org/TR/2011/WD-html5-20110525/timers.html + */ + toastLifeTimeMs: Number.MAX_SAFE_INTEGER - Date.now(), 'data-test-subj': 'completeReportSuccess', }); diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts index 66f905bd07cb21..ec8c51af534869 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver.ts @@ -156,7 +156,41 @@ export class HeadlessChromiumDriver { return !this.page.isClosed(); } + /** + * Despite having "preserveDrawingBuffer": "true" for WebGL driven canvas elements + * we may still get a blank canvas in PDFs. As a further mitigation + * we convert WebGL backed canvases to images and inline replace the canvas element. + * The visual result is identical. + * + * The drawback is that we are mutating the page and so if anything were to interact + * with it after we ran this function it may lead to issues. Ideally, once Chromium + * fixes how PDFs are generated we can remove this code. See: + * + * https://bugs.chromium.org/p/chromium/issues/detail?id=809065 + * https://bugs.chromium.org/p/chromium/issues/detail?id=137576 + * + * Idea adapted from: https://github.com/puppeteer/puppeteer/issues/1731#issuecomment-864345938 + */ + private async workaroundWebGLDrivenCanvases() { + const canvases = await this.page.$$('canvas'); + for (const canvas of canvases) { + await canvas.evaluate((thisCanvas: Element) => { + if ( + (thisCanvas as HTMLCanvasElement).getContext('webgl') || + (thisCanvas as HTMLCanvasElement).getContext('webgl2') + ) { + const newDiv = document.createElement('div'); + const img = document.createElement('img'); + img.src = (thisCanvas as HTMLCanvasElement).toDataURL('image/png'); + newDiv.appendChild(img); + thisCanvas.parentNode!.replaceChild(newDiv, thisCanvas); + } + }); + } + } + async printA4Pdf({ title, logo }: { title: string; logo?: string }): Promise { + await this.workaroundWebGLDrivenCanvases(); return this.page.pdf({ format: 'a4', preferCSSPageSize: true, diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 1d898901455e72..244143c8eeef6d 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -121,7 +121,6 @@ export enum SecurityPageName { usersExternalAlerts = 'users-external_alerts', threatHuntingLanding = 'threat-hunting', dashboardsLanding = 'dashboards', - manageLanding = 'manage', } export const THREAT_HUNTING_PATH = '/threat_hunting' as const; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index cf579f7ea5a904..2052a7e0b6cd91 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -54,9 +54,10 @@ export const AGENT_POLICY_SUMMARY_ROUTE = `${BASE_POLICY_ROUTE}/summaries`; export const ISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/isolate`; export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; -/** Endpoint Actions Log Routes */ +/** Endpoint Actions Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`; +export const ACTION_DETAILS_ROUTE = `/api/endpoint/action/{action_id}`; export const failedFleetActionErrorCode = '424'; diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts index 6ddb2fc19ef07e..00c157f9a2fd17 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts @@ -7,6 +7,7 @@ import seedrandom from 'seedrandom'; import uuid from 'uuid'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; const OS_FAMILY = ['windows', 'macos', 'linux']; /** Array of 14 day offsets */ @@ -180,4 +181,45 @@ export class BaseDataGenerator { protected randomHostname(): string { return `Host-${this.randomString(10)}`; } + + /** + * Returns an single search hit (normally found in a `SearchResponse`) for the given document source. + * @param hitSource + */ + toEsSearchHit(hitSource: T): estypes.SearchHit { + return { + _index: 'some-index', + _id: this.seededUUIDv4(), + _score: 1.0, + _source: hitSource, + }; + } + + /** + * Returns an ES Search Response for the give set of records. Each record will be wrapped with + * the `toEsSearchHit()` + * @param hitsSource + */ + toEsSearchResponse( + hitsSource: Array> + ): estypes.SearchResponse { + return { + took: 3, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: hitsSource.length, + relation: 'eq', + }, + max_score: 0, + hits: hitsSource, + }, + }; + } } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts index dd4eeeab15cce6..e0ae0f17adbde9 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_action_generator.ts @@ -7,23 +7,34 @@ import { DeepPartial } from 'utility-types'; import { merge } from 'lodash'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ENDPOINT_ACTION_RESPONSES_DS, ENDPOINT_ACTIONS_INDEX } from '../constants'; import { BaseDataGenerator } from './base_data_generator'; -import { ISOLATION_ACTIONS, LogsEndpointAction, LogsEndpointActionResponse } from '../types'; +import { + ActivityLogItemTypes, + EndpointActivityLogActionResponse, + ISOLATION_ACTIONS, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../types'; const ISOLATION_COMMANDS: ISOLATION_ACTIONS[] = ['isolate', 'unisolate']; export class EndpointActionGenerator extends BaseDataGenerator { /** Generate a random endpoint Action request (isolate or unisolate) */ generate(overrides: DeepPartial = {}): LogsEndpointAction { - const timeStamp = new Date(this.randomPastDate()); + const timeStamp = overrides['@timestamp'] + ? new Date(overrides['@timestamp']) + : new Date(this.randomPastDate()); + return merge( { '@timestamp': timeStamp.toISOString(), agent: { - id: [this.randomUUID()], + id: [this.seededUUIDv4()], }, EndpointActions: { - action_id: this.randomUUID(), + action_id: this.seededUUIDv4(), expiration: this.randomFutureDate(timeStamp), type: 'INPUT_ACTION', input_type: 'endpoint', @@ -41,6 +52,14 @@ export class EndpointActionGenerator extends BaseDataGenerator { ); } + generateActionEsHit( + overrides: DeepPartial = {} + ): estypes.SearchHit { + return Object.assign(this.toEsSearchHit(this.generate(overrides)), { + _index: `.ds-${ENDPOINT_ACTIONS_INDEX}-some_namespace`, + }); + } + generateIsolateAction(overrides: DeepPartial = {}): LogsEndpointAction { return merge(this.generate({ EndpointActions: { data: { command: 'isolate' } } }), overrides); } @@ -53,16 +72,16 @@ export class EndpointActionGenerator extends BaseDataGenerator { generateResponse( overrides: DeepPartial = {} ): LogsEndpointActionResponse { - const timeStamp = new Date(); + const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date(); return merge( { '@timestamp': timeStamp.toISOString(), agent: { - id: this.randomUUID(), + id: this.seededUUIDv4(), }, EndpointActions: { - action_id: this.randomUUID(), + action_id: this.seededUUIDv4(), completed_at: timeStamp.toISOString(), data: { command: this.randomIsolateCommand(), @@ -76,6 +95,29 @@ export class EndpointActionGenerator extends BaseDataGenerator { ); } + generateResponseEsHit( + overrides: DeepPartial = {} + ): estypes.SearchHit { + return Object.assign(this.toEsSearchHit(this.generateResponse(overrides)), { + _index: `.ds-${ENDPOINT_ACTION_RESPONSES_DS}-some_namespace-something`, + }); + } + + generateActivityLogActionResponse( + overrides: DeepPartial + ): EndpointActivityLogActionResponse { + return merge( + { + type: ActivityLogItemTypes.RESPONSE, + item: { + id: this.seededUUIDv4(), + data: this.generateResponse(), + }, + }, + overrides + ); + } + randomFloat(): number { return this.random(); } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts index abe29f62dfd5b5..aca71a2df6d51f 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts @@ -7,24 +7,34 @@ import { DeepPartial } from 'utility-types'; import { merge } from 'lodash'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; import { BaseDataGenerator } from './base_data_generator'; -import { EndpointAction, EndpointActionResponse, ISOLATION_ACTIONS } from '../types'; +import { + ActivityLogActionResponse, + ActivityLogItemTypes, + EndpointAction, + EndpointActionResponse, + ISOLATION_ACTIONS, +} from '../types'; const ISOLATION_COMMANDS: ISOLATION_ACTIONS[] = ['isolate', 'unisolate']; export class FleetActionGenerator extends BaseDataGenerator { /** Generate a random endpoint Action (isolate or unisolate) */ generate(overrides: DeepPartial = {}): EndpointAction { - const timeStamp = new Date(this.randomPastDate()); + const timeStamp = overrides['@timestamp'] + ? new Date(overrides['@timestamp']) + : new Date(this.randomPastDate()); return merge( { - action_id: this.randomUUID(), + action_id: this.seededUUIDv4(), '@timestamp': timeStamp.toISOString(), expiration: this.randomFutureDate(timeStamp), type: 'INPUT_ACTION', input_type: 'endpoint', - agents: [this.randomUUID()], + agents: [this.seededUUIDv4()], user_id: 'elastic', data: { command: this.randomIsolateCommand(), @@ -35,6 +45,14 @@ export class FleetActionGenerator extends BaseDataGenerator { ); } + generateActionEsHit( + overrides: DeepPartial = {} + ): estypes.SearchHit { + return Object.assign(this.toEsSearchHit(this.generate(overrides)), { + _index: AGENT_ACTIONS_INDEX, + }); + } + generateIsolateAction(overrides: DeepPartial = {}): EndpointAction { return merge(this.generate({ data: { command: 'isolate' } }), overrides); } @@ -45,7 +63,7 @@ export class FleetActionGenerator extends BaseDataGenerator { /** Generates an endpoint action response */ generateResponse(overrides: DeepPartial = {}): EndpointActionResponse { - const timeStamp = new Date(); + const timeStamp = overrides['@timestamp'] ? new Date(overrides['@timestamp']) : new Date(); return merge( { @@ -53,8 +71,8 @@ export class FleetActionGenerator extends BaseDataGenerator { command: this.randomIsolateCommand(), comment: '', }, - action_id: this.randomUUID(), - agent_id: this.randomUUID(), + action_id: this.seededUUIDv4(), + agent_id: this.seededUUIDv4(), started_at: this.randomPastDate(), completed_at: timeStamp.toISOString(), error: 'some error happened', @@ -64,6 +82,33 @@ export class FleetActionGenerator extends BaseDataGenerator { ); } + generateResponseEsHit( + overrides: DeepPartial = {} + ): estypes.SearchHit { + return Object.assign(this.toEsSearchHit(this.generateResponse(overrides)), { + _index: AGENT_ACTIONS_RESULTS_INDEX, + }); + } + + /** + * An Activity Log entry as returned by the Activity log API + * @param overrides + */ + generateActivityLogActionResponse( + overrides: DeepPartial = {} + ): ActivityLogActionResponse { + return merge( + { + type: ActivityLogItemTypes.FLEET_RESPONSE, + item: { + id: this.seededUUIDv4(), + data: this.generateResponse(), + }, + }, + overrides + ); + } + randomFloat(): number { return this.random(); } diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts index 69fce914cb1d5b..a13eb48865eddd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts @@ -42,3 +42,9 @@ export const ActionStatusRequestSchema = { ]), }), }; + +export const ActionDetailsRequestSchema = { + params: schema.object({ + action_id: schema.string(), + }), +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index 2ac4c9e772dedf..9ca07a26e03ed9 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -40,6 +40,11 @@ interface ActionResponseFields { completed_at: string; started_at: string; } + +/** + * An endpoint Action created in the Endpoint's `.logs-endpoint.actions-default` index. + * @since v7.16 + */ export interface LogsEndpointAction { '@timestamp': string; agent: { @@ -52,6 +57,10 @@ export interface LogsEndpointAction { }; } +/** + * An Action response written by the endpoint to the Endpoint `.logs-endpoint.action.responses` datastream + * @since v7.16 + */ export interface LogsEndpointActionResponse { '@timestamp': string; agent: { @@ -72,6 +81,9 @@ export interface FleetActionResponseData { }; } +/** + * And endpoint action created in Fleet's `.fleet-actions` + */ export interface EndpointAction { action_id: string; '@timestamp': string; @@ -136,11 +148,17 @@ export interface ActivityLogActionResponse { data: EndpointActionResponse; }; } + +/** + * One of the possible Response Action Log entry - Either a Fleet Action request, Fleet action response, + * Endpoint action request and/or endpoint action response. + */ export type ActivityLogEntry = | ActivityLogAction | ActivityLogActionResponse | EndpointActivityLogAction | EndpointActivityLogActionResponse; + export interface ActivityLog { page: number; pageSize: number; @@ -168,3 +186,32 @@ export interface PendingActionsResponse { } export type PendingActionsRequestQuery = TypeOf; + +export interface ActionDetails { + /** The action id */ + id: string; + /** + * The Endpoint ID (and fleet agent ID - they are the same) for which the action was created for. + * This is an Array because the action could have been sent to multiple endpoints. + */ + agents: string[]; + /** + * The Endpoint type of action (ex. `isolate`, `release`) that is being requested to be + * performed on the endpoint + */ + command: string; + isExpired: boolean; + isCompleted: boolean; + /** The date when the initial action request was submitted */ + startedAt: string; + /** The date when the action was completed (a response by the endpoint (not fleet) was received) */ + completedAt: string | undefined; + /** + * The list of action log items (actions and responses) received thus far for the action. + */ + logEntries: ActivityLogEntry[]; +} + +export interface ActionDetailsApiResponse { + data: ActionDetails; +} diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts new file mode 100644 index 00000000000000..3765dfadc8fccf --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/links.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; +import { CASES_PATH, SecurityPageName } from '../../common/constants'; +import { FEATURE, LinkItem } from '../common/links/types'; + +export const getCasesLinkItems = (): LinkItem => { + const casesLinks = getCasesDeepLinks({ + basePath: CASES_PATH, + extend: { + [SecurityPageName.case]: { + globalNavEnabled: true, + globalNavOrder: 9006, + features: [FEATURE.casesRead], + }, + [SecurityPageName.caseConfigure]: { + features: [FEATURE.casesCrud], + licenseType: 'gold', + }, + [SecurityPageName.caseCreate]: { + features: [FEATURE.casesCrud], + }, + }, + }); + const { id, deepLinks, ...rest } = casesLinks; + return { + ...rest, + id: SecurityPageName.case, + links: deepLinks as LinkItem[], + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 1cb8a918ea4813..bc20a98eae1e87 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -6,7 +6,7 @@ */ import { UrlStateType } from '../url_state/constants'; -import type { SecurityPageName } from '../../../app/types'; +import { SecurityPageName } from '../../../app/types'; import { UrlState } from '../url_state/types'; import { SiemRouteType } from '../../utils/route/types'; @@ -40,26 +40,27 @@ export interface NavTab { pageId?: SecurityPageName; isBeta?: boolean; } - -export type SecurityNavKey = - | SecurityPageName.administration - | SecurityPageName.alerts - | SecurityPageName.blocklist - | SecurityPageName.detectionAndResponse - | SecurityPageName.case - | SecurityPageName.endpoints - | SecurityPageName.landing - | SecurityPageName.policies - | SecurityPageName.eventFilters - | SecurityPageName.exceptions - | SecurityPageName.hostIsolationExceptions - | SecurityPageName.hosts - | SecurityPageName.network - | SecurityPageName.overview - | SecurityPageName.rules - | SecurityPageName.timelines - | SecurityPageName.trustedApps - | SecurityPageName.users; +export const securityNavKeys = [ + SecurityPageName.administration, + SecurityPageName.alerts, + SecurityPageName.blocklist, + SecurityPageName.detectionAndResponse, + SecurityPageName.case, + SecurityPageName.endpoints, + SecurityPageName.landing, + SecurityPageName.policies, + SecurityPageName.eventFilters, + SecurityPageName.exceptions, + SecurityPageName.hostIsolationExceptions, + SecurityPageName.hosts, + SecurityPageName.network, + SecurityPageName.overview, + SecurityPageName.rules, + SecurityPageName.timelines, + SecurityPageName.trustedApps, + SecurityPageName.users, +] as const; +export type SecurityNavKey = typeof securityNavKeys[number]; export type SecurityNav = Record; diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts new file mode 100644 index 00000000000000..4a972bd5deb1f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/app_links.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { SecurityPageName, THREAT_HUNTING_PATH } from '../../../common/constants'; +import { THREAT_HUNTING } from '../../app/translations'; +import { FEATURE, LinkItem, UserPermissions } from './types'; +import { links as hostsLinks } from '../../hosts/links'; +import { links as detectionLinks } from '../../detections/links'; +import { links as networkLinks } from '../../network/links'; +import { links as usersLinks } from '../../users/links'; +import { links as timelinesLinks } from '../../timelines/links'; +import { getCasesLinkItems } from '../../cases/links'; +import { links as managementLinks } from '../../management/links'; +import { gettingStartedLinks, dashboardsLandingLinks } from '../../overview/links'; + +export const appLinks: Readonly = Object.freeze([ + gettingStartedLinks, + dashboardsLandingLinks, + detectionLinks, + { + id: SecurityPageName.threatHuntingLanding, + title: THREAT_HUNTING, + path: THREAT_HUNTING_PATH, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.threatHunting', { + defaultMessage: 'Threat hunting', + }), + ], + links: [hostsLinks, networkLinks, usersLinks], + }, + timelinesLinks, + getCasesLinkItems(), + managementLinks, +]); + +export const getAppLinks = async ({ + enableExperimental, + license, + capabilities, +}: UserPermissions) => { + // OLM team, implement async behavior here + return appLinks; +}; diff --git a/x-pack/plugins/security_solution/public/common/links/index.tsx b/x-pack/plugins/security_solution/public/common/links/index.tsx new file mode 100644 index 00000000000000..6d8e99cd416d2f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/index.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './links'; diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.ts b/x-pack/plugins/security_solution/public/common/links/links.test.ts new file mode 100644 index 00000000000000..d8f6711cfc6295 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/links.test.ts @@ -0,0 +1,421 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getAncestorLinksInfo, + getDeepLinks, + getInitialDeepLinks, + getLinkInfo, + getNavLinkItems, + needsUrlState, +} from './links'; +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { Capabilities } from '@kbn/core/types'; +import { AppDeepLink } from '@kbn/core/public'; +import { mockGlobalState } from '../mock'; +import { NavLinkItem } from './types'; +import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { LicenseService } from '../../../common/license'; + +const mockExperimentalDefaults = mockGlobalState.app.enableExperimental; +const mockCapabilities = { + [CASES_FEATURE_ID]: { read_cases: true, crud_cases: true }, + [SERVER_APP_ID]: { show: true }, +} as unknown as Capabilities; + +const findDeepLink = (id: string, deepLinks: AppDeepLink[]): AppDeepLink | null => + deepLinks.reduce((deepLinkFound: AppDeepLink | null, deepLink) => { + if (deepLinkFound !== null) { + return deepLinkFound; + } + if (deepLink.id === id) { + return deepLink; + } + if (deepLink.deepLinks) { + return findDeepLink(id, deepLink.deepLinks); + } + return null; + }, null); + +const findNavLink = (id: SecurityPageName, navLinks: NavLinkItem[]): NavLinkItem | null => + navLinks.reduce((deepLinkFound: NavLinkItem | null, deepLink) => { + if (deepLinkFound !== null) { + return deepLinkFound; + } + if (deepLink.id === id) { + return deepLink; + } + if (deepLink.links) { + return findNavLink(id, deepLink.links); + } + return null; + }, null); + +// remove filter once new nav is live +const allPages = Object.values(SecurityPageName).filter( + (pageName) => + pageName !== SecurityPageName.explore && + pageName !== SecurityPageName.detections && + pageName !== SecurityPageName.investigate +); +const casesPages = [ + SecurityPageName.case, + SecurityPageName.caseConfigure, + SecurityPageName.caseCreate, +]; +const featureFlagPages = [ + SecurityPageName.detectionAndResponse, + SecurityPageName.hostsAuthentications, + SecurityPageName.hostsRisk, + SecurityPageName.usersRisk, +]; +const premiumPages = [ + SecurityPageName.caseConfigure, + SecurityPageName.hostsAnomalies, + SecurityPageName.networkAnomalies, + SecurityPageName.usersAnomalies, + SecurityPageName.detectionAndResponse, + SecurityPageName.hostsRisk, + SecurityPageName.usersRisk, +]; +const nonCasesPages = allPages.reduce( + (acc: SecurityPageName[], p) => + casesPages.includes(p) || featureFlagPages.includes(p) ? acc : [p, ...acc], + [] +); + +const licenseBasicMock = jest.fn().mockImplementation((arg: LicenseType) => arg === 'basic'); +const licensePremiumMock = jest.fn().mockReturnValue(true); +const mockLicense = { + isAtLeast: licensePremiumMock, +} as unknown as LicenseService; + +describe('security app link helpers', () => { + beforeEach(() => { + mockLicense.isAtLeast = licensePremiumMock; + }); + describe('getInitialDeepLinks', () => { + it('should return all pages in the app', () => { + const links = getInitialDeepLinks(); + allPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); + }); + }); + describe('getDeepLinks', () => { + it('basicLicense should return only basic links', async () => { + mockLicense.isAtLeast = licenseBasicMock; + + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); + allPages.forEach((page) => { + if (premiumPages.includes(page)) { + return expect(findDeepLink(page, links)).toBeFalsy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findDeepLink(page, links)).toBeTruthy(); + }); + }); + it('platinumLicense should return all links', async () => { + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + allPages.forEach((page) => { + if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { + return expect(findDeepLink(page, links)).toBeTruthy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findDeepLink(page, links)).toBeTruthy(); + }); + }); + it('hideWhenExperimentalKey hides entry when key = true', async () => { + const links = await getDeepLinks({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); + }); + it('hideWhenExperimentalKey shows entry when key = false', async () => { + const links = await getDeepLinks({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); + }); + it('experimentalKey shows entry when key = false', async () => { + const links = await getDeepLinks({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: false, + riskyUsersEnabled: false, + detectionResponseEnabled: false, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); + expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeFalsy(); + expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); + }); + it('experimentalKey shows entry when key = true', async () => { + const links = await getDeepLinks({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: true, + riskyUsersEnabled: true, + detectionResponseEnabled: true, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); + expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeTruthy(); + expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + }); + + it('Removes siem features when siem capabilities are false', async () => { + const capabilities = { + ...mockCapabilities, + [SERVER_APP_ID]: { show: false }, + } as unknown as Capabilities; + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => { + // investigate is active for both Cases and Timelines pages + if (page === SecurityPageName.investigate) { + return expect(findDeepLink(page, links)).toBeTruthy(); + } + return expect(findDeepLink(page, links)).toBeFalsy(); + }); + casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); + }); + it('Removes cases features when cases capabilities are false', async () => { + const capabilities = { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + } as unknown as Capabilities; + const links = await getDeepLinks({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); + casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeFalsy()); + }); + }); + + describe('getNavLinkItems', () => { + it('basicLicense should return only basic links', () => { + mockLicense.isAtLeast = licenseBasicMock; + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); + allPages.forEach((page) => { + if (premiumPages.includes(page)) { + return expect(findNavLink(page, links)).toBeFalsy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findNavLink(page, links)).toBeTruthy(); + }); + }); + it('platinumLicense should return all links', () => { + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities: mockCapabilities, + }); + allPages.forEach((page) => { + if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { + return expect(findNavLink(page, links)).toBeTruthy(); + } + if (featureFlagPages.includes(page)) { + // ignore feature flag pages + return; + } + expect(findNavLink(page, links)).toBeTruthy(); + }); + }); + it('hideWhenExperimentalKey hides entry when key = true', () => { + const links = getNavLinkItems({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); + }); + it('hideWhenExperimentalKey shows entry when key = false', () => { + const links = getNavLinkItems({ + enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); + }); + it('experimentalKey shows entry when key = false', () => { + const links = getNavLinkItems({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: false, + riskyUsersEnabled: false, + detectionResponseEnabled: false, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); + expect(findNavLink(SecurityPageName.usersRisk, links)).toBeFalsy(); + expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); + }); + it('experimentalKey shows entry when key = true', () => { + const links = getNavLinkItems({ + enableExperimental: { + ...mockExperimentalDefaults, + riskyHostsEnabled: true, + riskyUsersEnabled: true, + detectionResponseEnabled: true, + }, + license: mockLicense, + capabilities: mockCapabilities, + }); + expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); + expect(findNavLink(SecurityPageName.usersRisk, links)).toBeTruthy(); + expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + }); + + it('Removes siem features when siem capabilities are false', () => { + const capabilities = { + ...mockCapabilities, + [SERVER_APP_ID]: { show: false }, + } as unknown as Capabilities; + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => { + // investigate is active for both Cases and Timelines pages + if (page === SecurityPageName.investigate) { + return expect(findNavLink(page, links)).toBeTruthy(); + } + return expect(findNavLink(page, links)).toBeFalsy(); + }); + casesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); + }); + it('Removes cases features when cases capabilities are false', () => { + const capabilities = { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + } as unknown as Capabilities; + const links = getNavLinkItems({ + enableExperimental: mockExperimentalDefaults, + license: mockLicense, + capabilities, + }); + nonCasesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); + casesPages.forEach((page) => expect(findNavLink(page, links)).toBeFalsy()); + }); + }); + + describe('getAncestorLinksInfo', () => { + it('finds flattened links for hosts', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.hosts); + expect(hierarchy).toEqual([ + { + features: ['siem.show'], + globalNavEnabled: false, + globalSearchKeywords: ['Threat hunting'], + id: 'threat-hunting', + path: '/threat_hunting', + title: 'Threat Hunting', + }, + { + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + }, + ]); + }); + it('finds flattened links for uncommonProcesses', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.uncommonProcesses); + expect(hierarchy).toEqual([ + { + features: ['siem.show'], + globalNavEnabled: false, + globalSearchKeywords: ['Threat hunting'], + id: 'threat-hunting', + path: '/threat_hunting', + title: 'Threat Hunting', + }, + { + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + }, + { + id: 'uncommon_processes', + path: '/hosts/uncommonProcesses', + title: 'Uncommon Processes', + }, + ]); + }); + }); + + describe('needsUrlState', () => { + it('returns true when url state exists for page', () => { + const needsUrl = needsUrlState(SecurityPageName.hosts); + expect(needsUrl).toEqual(true); + }); + it('returns false when url state does not exist for page', () => { + const needsUrl = needsUrlState(SecurityPageName.landing); + expect(needsUrl).toEqual(false); + }); + }); + + describe('getLinkInfo', () => { + it('gets information for an individual link', () => { + const linkInfo = getLinkInfo(SecurityPageName.hosts); + expect(linkInfo).toEqual({ + globalNavEnabled: true, + globalNavOrder: 9002, + globalSearchEnabled: true, + globalSearchKeywords: ['Hosts'], + id: 'hosts', + path: '/hosts', + title: 'Hosts', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts new file mode 100644 index 00000000000000..290a1f3fbd8208 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import { get } from 'lodash'; +import { SecurityPageName } from '../../../common/constants'; +import { appLinks, getAppLinks } from './app_links'; +import { + Feature, + LinkInfo, + LinkItem, + NavLinkItem, + NormalizedLink, + NormalizedLinks, + UserPermissions, +} from './types'; + +const createDeepLink = (link: LinkItem, linkProps?: UserPermissions): AppDeepLink => ({ + id: link.id, + path: link.path, + title: link.title, + ...(link.links && link.links.length + ? { + deepLinks: reduceLinks({ + links: link.links, + linkProps, + formatFunction: createDeepLink, + }), + } + : {}), + ...(link.icon != null ? { euiIconType: link.icon } : {}), + ...(link.image != null ? { icon: link.image } : {}), + ...(link.globalSearchKeywords != null ? { keywords: link.globalSearchKeywords } : {}), + ...(link.globalNavEnabled != null + ? { navLinkStatus: link.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden } + : {}), + ...(link.globalNavOrder != null ? { order: link.globalNavOrder } : {}), + ...(link.globalSearchEnabled != null ? { searchable: link.globalSearchEnabled } : {}), +}); + +const createNavLinkItem = (link: LinkItem, linkProps?: UserPermissions): NavLinkItem => ({ + id: link.id, + path: link.path, + title: link.title, + ...(link.description != null ? { description: link.description } : {}), + ...(link.icon != null ? { icon: link.icon } : {}), + ...(link.image != null ? { image: link.image } : {}), + ...(link.links && link.links.length + ? { + links: reduceLinks({ + links: link.links, + linkProps, + formatFunction: createNavLinkItem, + }), + } + : {}), + ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), +}); + +const hasFeaturesCapability = ( + features: Feature[] | undefined, + capabilities: Capabilities +): boolean => { + if (!features) { + return true; + } + return features.some((featureKey) => get(capabilities, featureKey, false)); +}; + +const isLinkAllowed = (link: LinkItem, linkProps?: UserPermissions) => + !( + linkProps != null && + // exclude link when license is basic and link is premium + ((linkProps.license && !linkProps.license.isAtLeast(link.licenseType ?? 'basic')) || + // exclude link when enableExperimental[hideWhenExperimentalKey] is enabled and link has hideWhenExperimentalKey + (link.hideWhenExperimentalKey != null && + linkProps.enableExperimental[link.hideWhenExperimentalKey]) || + // exclude link when enableExperimental[experimentalKey] is disabled and link has experimentalKey + (link.experimentalKey != null && !linkProps.enableExperimental[link.experimentalKey]) || + // exclude link when link is not part of enabled feature capabilities + (linkProps.capabilities != null && + !hasFeaturesCapability(link.features, linkProps.capabilities))) + ); + +export function reduceLinks({ + links, + linkProps, + formatFunction, +}: { + links: Readonly; + linkProps?: UserPermissions; + formatFunction: (link: LinkItem, linkProps?: UserPermissions) => T; +}): T[] { + return links.reduce( + (deepLinks: T[], link: LinkItem) => + isLinkAllowed(link, linkProps) ? [...deepLinks, formatFunction(link, linkProps)] : deepLinks, + [] + ); +} + +export const getInitialDeepLinks = (): AppDeepLink[] => { + return appLinks.map((link) => createDeepLink(link)); +}; + +export const getDeepLinks = async ({ + enableExperimental, + license, + capabilities, +}: UserPermissions): Promise => { + const links = await getAppLinks({ enableExperimental, license, capabilities }); + return reduceLinks({ + links, + linkProps: { enableExperimental, license, capabilities }, + formatFunction: createDeepLink, + }); +}; + +export const getNavLinkItems = ({ + enableExperimental, + license, + capabilities, +}: UserPermissions): NavLinkItem[] => + reduceLinks({ + links: appLinks, + linkProps: { enableExperimental, license, capabilities }, + formatFunction: createNavLinkItem, + }); + +/** + * Recursive function to create the `NormalizedLinks` structure from a `LinkItem` array parameter + */ +const getNormalizedLinks = ( + currentLinks: Readonly, + parentId?: SecurityPageName +): NormalizedLinks => { + const result = currentLinks.reduce>( + (normalized, { links, ...currentLink }) => { + normalized[currentLink.id] = { + ...currentLink, + parentId, + }; + if (links && links.length > 0) { + Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); + } + return normalized; + }, + {} + ); + return result as NormalizedLinks; +}; + +/** + * Normalized indexed version of the global `links` array, referencing the parent by id, instead of having nested links children + */ +const normalizedLinks: Readonly = Object.freeze(getNormalizedLinks(appLinks)); + +/** + * Returns the `NormalizedLink` from a link id parameter. + * The object reference is frozen to make sure it is not mutated by the caller. + */ +const getNormalizedLink = (id: SecurityPageName): Readonly => + Object.freeze(normalizedLinks[id]); + +/** + * Returns the `LinkInfo` from a link id parameter + */ +export const getLinkInfo = (id: SecurityPageName): LinkInfo => { + // discards the parentId and creates the linkInfo copy. + const { parentId, ...linkInfo } = getNormalizedLink(id); + return linkInfo; +}; + +/** + * Returns the `LinkInfo` of all the ancestors to the parameter id link, also included. + */ +export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { + const ancestors: LinkInfo[] = []; + let currentId: SecurityPageName | undefined = id; + while (currentId) { + const { parentId, ...linkInfo } = getNormalizedLink(currentId); + ancestors.push(linkInfo); + currentId = parentId; + } + return ancestors.reverse(); +}; + +/** + * Returns `true` if the links needs to carry the application state in the url. + * Defaults to `true` if the `skipUrlState` property of the `LinkItem` is `undefined`. + */ +export const needsUrlState = (id: SecurityPageName): boolean => { + return !getNormalizedLink(id).skipUrlState; +}; diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts new file mode 100644 index 00000000000000..eea348b3df7377 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/links/types.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Capabilities } from '@kbn/core/types'; +import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { LicenseService } from '../../../common/license'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; + +export const FEATURE = { + general: `${SERVER_APP_ID}.show`, + casesRead: `${CASES_FEATURE_ID}.read_cases`, + casesCrud: `${CASES_FEATURE_ID}.crud_cases`, +}; + +export type Feature = Readonly; + +export interface UserPermissions { + enableExperimental: ExperimentalFeatures; + license?: LicenseService; + capabilities?: Capabilities; +} + +export interface LinkItem { + description?: string; + disabled?: boolean; // default false + /** + * Displays deep link when feature flag is enabled. + */ + experimentalKey?: keyof ExperimentalFeatures; + features?: Feature[]; + /** + * Hides deep link when feature flag is enabled. + */ + globalNavEnabled?: boolean; // default false + globalNavOrder?: number; + globalSearchEnabled?: boolean; + globalSearchKeywords?: string[]; + hideWhenExperimentalKey?: keyof ExperimentalFeatures; + icon?: string; + id: SecurityPageName; + image?: string; + isBeta?: boolean; + licenseType?: LicenseType; + links?: LinkItem[]; + path: string; + skipUrlState?: boolean; // defaults to false + title: string; +} + +export interface NavLinkItem { + description?: string; + icon?: string; + id: SecurityPageName; + links?: NavLinkItem[]; + image?: string; + path: string; + title: string; + skipUrlState?: boolean; // default to false +} + +export type LinkInfo = Omit; +export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName }; +export type NormalizedLinks = Record; diff --git a/x-pack/plugins/security_solution/public/detections/links.ts b/x-pack/plugins/security_solution/public/detections/links.ts new file mode 100644 index 00000000000000..1cfac62d80e6e9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/links.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { ALERTS_PATH, SecurityPageName } from '../../common/constants'; +import { ALERTS } from '../app/translations'; +import { LinkItem, FEATURE } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.alerts, + title: ALERTS, + path: ALERTS_PATH, + features: [FEATURE.general], + globalNavEnabled: true, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.alerts', { + defaultMessage: 'Alerts', + }), + ], + globalSearchEnabled: true, + globalNavOrder: 9001, +}; diff --git a/x-pack/plugins/security_solution/public/hosts/links.ts b/x-pack/plugins/security_solution/public/hosts/links.ts new file mode 100644 index 00000000000000..35730291d6c749 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/links.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +import { HOSTS_PATH, SecurityPageName } from '../../common/constants'; +import { HOSTS } from '../app/translations'; +import { LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.hosts, + title: HOSTS, + path: HOSTS_PATH, + globalNavEnabled: true, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.hosts', { + defaultMessage: 'Hosts', + }), + ], + globalSearchEnabled: true, + globalNavOrder: 9002, + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.authentications', { + defaultMessage: 'Authentications', + }), + path: `${HOSTS_PATH}/authentications`, + hideWhenExperimentalKey: 'usersEnabled', + }, + { + id: SecurityPageName.uncommonProcesses, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.uncommonProcesses', { + defaultMessage: 'Uncommon Processes', + }), + path: `${HOSTS_PATH}/uncommonProcesses`, + }, + { + id: SecurityPageName.hostsAnomalies, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.anomalies', { + defaultMessage: 'Anomalies', + }), + path: `${HOSTS_PATH}/anomalies`, + licenseType: 'gold', + }, + { + id: SecurityPageName.hostsEvents, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.events', { + defaultMessage: 'Events', + }), + path: `${HOSTS_PATH}/events`, + }, + { + id: SecurityPageName.hostsExternalAlerts, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.externalAlerts', { + defaultMessage: 'External Alerts', + }), + path: `${HOSTS_PATH}/externalAlerts`, + }, + { + id: SecurityPageName.hostsRisk, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.risk', { + defaultMessage: 'Hosts by risk', + }), + path: `${HOSTS_PATH}/hostRisk`, + experimentalKey: 'riskyHostsEnabled', + }, + { + id: SecurityPageName.sessions, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.sessions', { + defaultMessage: 'Sessions', + }), + path: `${HOSTS_PATH}/sessions`, + isBeta: true, + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/landing_pages/routes.tsx b/x-pack/plugins/security_solution/public/landing_pages/routes.tsx index af8ce9dbdaf2a4..3fbe33cc0ec883 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/routes.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/routes.tsx @@ -27,7 +27,7 @@ export const DashboardRoutes = () => ( ); export const ManageRoutes = () => ( - + ); diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts new file mode 100644 index 00000000000000..d941d538c80f7c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + BLOCKLIST_PATH, + ENDPOINTS_PATH, + EVENT_FILTERS_PATH, + EXCEPTIONS_PATH, + HOST_ISOLATION_EXCEPTIONS_PATH, + MANAGEMENT_PATH, + POLICIES_PATH, + RULES_PATH, + SecurityPageName, + TRUSTED_APPS_PATH, +} from '../../common/constants'; +import { + BLOCKLIST, + ENDPOINTS, + EVENT_FILTERS, + EXCEPTIONS, + HOST_ISOLATION_EXCEPTIONS, + MANAGE, + POLICIES, + RULES, + TRUSTED_APPLICATIONS, +} from '../app/translations'; +import { FEATURE, LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.administration, + title: MANAGE, + path: MANAGEMENT_PATH, + skipUrlState: true, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.manage', { + defaultMessage: 'Manage', + }), + ], + links: [ + { + id: SecurityPageName.rules, + title: RULES, + path: RULES_PATH, + globalNavEnabled: false, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.rules', { + defaultMessage: 'Rules', + }), + ], + globalSearchEnabled: true, + }, + { + id: SecurityPageName.exceptions, + title: EXCEPTIONS, + path: EXCEPTIONS_PATH, + globalNavEnabled: false, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.exceptions', { + defaultMessage: 'Exception lists', + }), + ], + globalSearchEnabled: true, + }, + { + id: SecurityPageName.endpoints, + globalNavEnabled: true, + title: ENDPOINTS, + globalNavOrder: 9006, + path: ENDPOINTS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.policies, + title: POLICIES, + path: POLICIES_PATH, + skipUrlState: true, + experimentalKey: 'policyListEnabled', + }, + { + id: SecurityPageName.trustedApps, + title: TRUSTED_APPLICATIONS, + path: TRUSTED_APPS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.eventFilters, + title: EVENT_FILTERS, + path: EVENT_FILTERS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.hostIsolationExceptions, + title: HOST_ISOLATION_EXCEPTIONS, + path: HOST_ISOLATION_EXCEPTIONS_PATH, + skipUrlState: true, + }, + { + id: SecurityPageName.blocklist, + title: BLOCKLIST, + path: BLOCKLIST_PATH, + skipUrlState: true, + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/network/links.ts b/x-pack/plugins/security_solution/public/network/links.ts new file mode 100644 index 00000000000000..ad209a220eebcb --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/links.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { NETWORK_PATH, SecurityPageName } from '../../common/constants'; +import { NETWORK } from '../app/translations'; +import { LinkItem } from '../common/links/types'; + +export const links: LinkItem = { + id: SecurityPageName.network, + title: NETWORK, + path: NETWORK_PATH, + globalNavEnabled: true, + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.network', { + defaultMessage: 'Network', + }), + ], + globalNavOrder: 9003, + links: [ + { + id: SecurityPageName.networkDns, + title: i18n.translate('xpack.securitySolution.appLinks.network.dns', { + defaultMessage: 'DNS', + }), + path: `${NETWORK_PATH}/dns`, + }, + { + id: SecurityPageName.networkHttp, + title: i18n.translate('xpack.securitySolution.appLinks.network.http', { + defaultMessage: 'HTTP', + }), + path: `${NETWORK_PATH}/http`, + }, + { + id: SecurityPageName.networkTls, + title: i18n.translate('xpack.securitySolution.appLinks.network.tls', { + defaultMessage: 'TLS', + }), + path: `${NETWORK_PATH}/tls`, + }, + { + id: SecurityPageName.networkExternalAlerts, + title: i18n.translate('xpack.securitySolution.appLinks.network.externalAlerts', { + defaultMessage: 'External Alerts', + }), + path: `${NETWORK_PATH}/external-alerts`, + }, + { + id: SecurityPageName.networkAnomalies, + title: i18n.translate('xpack.securitySolution.appLinks.hosts.anomalies', { + defaultMessage: 'Anomalies', + }), + path: `${NETWORK_PATH}/anomalies`, + licenseType: 'gold', + }, + ], +}; diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts new file mode 100644 index 00000000000000..89f75053b3d6f9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/links.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { + DASHBOARDS_PATH, + DETECTION_RESPONSE_PATH, + LANDING_PATH, + OVERVIEW_PATH, + SecurityPageName, +} from '../../common/constants'; +import { DASHBOARDS, DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; +import { FEATURE, LinkItem } from '../common/links/types'; + +export const overviewLinks: LinkItem = { + id: SecurityPageName.overview, + title: OVERVIEW, + path: OVERVIEW_PATH, + globalNavEnabled: true, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.overview', { + defaultMessage: 'Overview', + }), + ], + globalNavOrder: 9000, +}; + +export const gettingStartedLinks: LinkItem = { + id: SecurityPageName.landing, + title: GETTING_STARTED, + path: LANDING_PATH, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.getStarted', { + defaultMessage: 'Getting started', + }), + ], + skipUrlState: true, +}; + +export const detectionResponseLinks: LinkItem = { + id: SecurityPageName.detectionAndResponse, + title: DETECTION_RESPONSE, + path: DETECTION_RESPONSE_PATH, + globalNavEnabled: false, + experimentalKey: 'detectionResponseEnabled', + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.detectionAndResponse', { + defaultMessage: 'Detection & Response', + }), + ], +}; + +export const dashboardsLandingLinks: LinkItem = { + id: SecurityPageName.dashboardsLanding, + title: DASHBOARDS, + path: DASHBOARDS_PATH, + globalNavEnabled: false, + features: [FEATURE.general], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.dashboards', { + defaultMessage: 'Dashboards', + }), + ], + links: [overviewLinks, detectionResponseLinks], +}; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 343259d88cb762..4b49c04f295a5c 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -222,6 +222,7 @@ export class Plugin implements IPlugin { + let mockScopedEsClient: ScopedClusterClientMock; + let mockSavedObjectClient: jest.Mocked; + let mockResponse: jest.Mocked; + let actionDetailsRouteHandler: ReturnType; + + beforeEach(() => { + mockScopedEsClient = elasticsearchServiceMock.createScopedClusterClient(); + mockSavedObjectClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + actionDetailsRouteHandler = getActionDetailsRequestHandler(createMockEndpointAppContext()); + }); + + it('should call service using action id from request', async () => { + applyActionsEsSearchMock(mockScopedEsClient.asInternalUser); + + const mockContext = requestContextMock.convertContext( + createRouteHandlerContext(mockScopedEsClient, mockSavedObjectClient) + ); + const mockRequest = httpServerMock.createKibanaRequest< + TypeOf, + never, + never + >({ + params: { action_id: 'a-b-c' }, + }); + + await actionDetailsRouteHandler(mockContext, mockRequest, mockResponse); + + expect(mockScopedEsClient.asInternalUser.search).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + body: { + query: { + bool: { + filter: expect.arrayContaining([{ term: { action_id: 'a-b-c' } }]), + }, + }, + }, + }), + expect.any(Object) + ); + + expect(mockResponse.ok).toHaveBeenCalled(); + }); + + it('should respond with 404 if action id not found', async () => { + applyActionsEsSearchMock( + mockScopedEsClient.asInternalUser, + new EndpointActionGenerator().toEsSearchResponse([]) + ); + + const mockContext = requestContextMock.convertContext( + createRouteHandlerContext(mockScopedEsClient, mockSavedObjectClient) + ); + const mockRequest = httpServerMock.createKibanaRequest< + TypeOf, + never, + never + >({ + params: { action_id: '123' }, + }); + + await actionDetailsRouteHandler(mockContext, mockRequest, mockResponse); + + expect(mockResponse.notFound).toHaveBeenCalledWith({ + body: expect.any(NotFoundError), + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/details.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/details.ts new file mode 100644 index 00000000000000..a5ba924a42728a --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/details.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RequestHandler } from '@kbn/core/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import { EndpointAppContext } from '../../types'; +import { ACTION_DETAILS_ROUTE } from '../../../../common/endpoint/constants'; +import { ActionDetailsRequestSchema } from '../../../../common/endpoint/schema/actions'; +import { withEndpointAuthz } from '../with_endpoint_authz'; +import { getActionDetailsById } from '../../services'; +import { errorHandler } from '../error_handler'; + +/** + * Registers the route for handling retrieval of Action Details + * @param router + * @param endpointContext + */ +export const registerActionDetailsRoutes = ( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext +) => { + // Details for a given action id + router.get( + { + path: ACTION_DETAILS_ROUTE, + validate: ActionDetailsRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + withEndpointAuthz( + { all: ['canAccessEndpointManagement'] }, + endpointContext.logFactory.get('hostIsolationDetails'), + getActionDetailsRequestHandler(endpointContext) + ) + ); +}; + +export const getActionDetailsRequestHandler = ( + endpointContext: EndpointAppContext +): RequestHandler< + TypeOf, + never, + never, + SecuritySolutionRequestHandlerContext +> => { + return async (context, req, res) => { + try { + return res.ok({ + body: { + data: await getActionDetailsById( + ( + await context.core + ).elasticsearch.client.asInternalUser, + req.params.action_id + ), + }, + }); + } catch (error) { + return errorHandler(endpointContext.logFactory.get('EndpointActionDetails'), res, error); + } + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index 3245369b56e40e..baa9440ae8d0c5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { registerActionDetailsRoutes } from './details'; import { SecuritySolutionPluginRouter } from '../../../types'; import { EndpointAppContext } from '../../types'; import { registerHostIsolationRoutes } from './isolation'; @@ -22,4 +23,5 @@ export function registerActionRoutes( registerHostIsolationRoutes(router, endpointContext); registerActionStatusRoutes(router, endpointContext); registerActionAuditLogRoutes(router, endpointContext); + registerActionDetailsRoutes(router, endpointContext); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index 69954dbd7e7a7a..c640f56efb5127 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -190,6 +190,7 @@ export const isolationRequestHandler = function ( body: { ...doc, }, + refresh: 'wait_for', }, { meta: true } ); @@ -221,6 +222,7 @@ export const isolationRequestHandler = function ( timeout: 300, // 5 minutes user_id: doc.user.id, }, + refresh: 'wait_for', }, { meta: true } ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts index bfd17cccb3d0d7..61b2b9c56f5b08 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/status.ts @@ -18,12 +18,13 @@ import { getPendingActionCounts } from '../../services'; import { withEndpointAuthz } from '../with_endpoint_authz'; /** - * Registers routes for checking status of endpoints based on pending actions + * Registers routes for checking status of actions */ export function registerActionStatusRoutes( router: SecuritySolutionPluginRouter, endpointContext: EndpointAppContext ) { + // Summary of action status for a given list of endpoints router.get( { path: ACTION_STATUS_ROUTE, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts new file mode 100644 index 00000000000000..065ab835dbf998 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/error_handler.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IKibanaResponse, KibanaResponseFactory, Logger } from '@kbn/core/server'; +import { CustomHttpRequestError } from '../../utils/custom_http_request_error'; +import { NotFoundError } from '../errors'; +import { EndpointHostUnEnrolledError } from '../services/metadata'; + +/** + * Default Endpoint Routes error handler + * @param logger + * @param res + * @param error + */ +export const errorHandler = ( + logger: Logger, + res: KibanaResponseFactory, + error: E +): IKibanaResponse => { + logger.error(error); + + if (error instanceof CustomHttpRequestError) { + return res.customError({ + statusCode: error.statusCode, + body: error, + }); + } + + if (error instanceof NotFoundError) { + return res.notFound({ body: error }); + } + + if (error instanceof EndpointHostUnEnrolledError) { + return res.badRequest({ body: error }); + } + + // Kibana CORE will take care of `500` errors when the handler `throw`'s, including logging the error + throw error; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 1b86924101b68c..f9aa361e71f32c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -7,15 +7,14 @@ import { TypeOf } from '@kbn/config-schema'; import { - IKibanaResponse, IScopedClusterClient, - KibanaResponseFactory, Logger, RequestHandler, SavedObjectsClientContract, } from '@kbn/core/server'; import { PackagePolicy } from '@kbn/fleet-plugin/common/types/models'; import { AgentNotFoundError } from '@kbn/fleet-plugin/server'; +import { errorHandler } from '../error_handler'; import { HostInfo, HostMetadata, @@ -33,9 +32,6 @@ import { findAgentIdsByStatus } from './support/agent_status'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { fleetAgentStatusToEndpointHostStatus } from '../../utils'; import { queryResponseToHostListResult } from './support/query_strategies'; -import { NotFoundError } from '../../errors'; -import { EndpointHostUnEnrolledError } from '../../services/metadata'; -import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; import { GetMetadataListRequestQuery } from '../../../../common/endpoint/schema/metadata'; import { ENDPOINT_DEFAULT_PAGE, @@ -56,32 +52,6 @@ export const getLogger = (endpointAppContext: EndpointAppContext): Logger => { return endpointAppContext.logFactory.get('metadata'); }; -const errorHandler = ( - logger: Logger, - res: KibanaResponseFactory, - error: E -): IKibanaResponse => { - logger.error(error); - - if (error instanceof CustomHttpRequestError) { - return res.customError({ - statusCode: error.statusCode, - body: error, - }); - } - - if (error instanceof NotFoundError) { - return res.notFound({ body: error }); - } - - if (error instanceof EndpointHostUnEnrolledError) { - return res.badRequest({ body: error }); - } - - // Kibana CORE will take care of `500` errors when the handler `throw`'s, including logging the error - throw error; -}; - export function getMetadataListRequestHandler( endpointAppContext: EndpointAppContext, logger: Logger diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts new file mode 100644 index 00000000000000..b977009b15315c --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.test.ts @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClientMock } from '@kbn/core/server/mocks'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { + EndpointAction, + EndpointActionResponse, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../../../../common/endpoint/types'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { getActionDetailsById } from '..'; +import { NotFoundError } from '../../errors'; +import { + applyActionsEsSearchMock, + createActionRequestsEsSearchResultsMock, + createActionResponsesEsSearchResultsMock, +} from './mocks'; + +describe('When using `getActionDetailsById()', () => { + let esClient: ElasticsearchClientMock; + let endpointActionGenerator: EndpointActionGenerator; + let actionRequests: estypes.SearchResponse; + let actionResponses: estypes.SearchResponse; + + beforeEach(() => { + esClient = elasticsearchServiceMock.createScopedClusterClient().asInternalUser; + endpointActionGenerator = new EndpointActionGenerator('seed'); + + actionRequests = createActionRequestsEsSearchResultsMock(); + actionResponses = createActionResponsesEsSearchResultsMock(); + + applyActionsEsSearchMock(esClient, actionRequests, actionResponses); + }); + + it('should return expected output', async () => { + await expect(getActionDetailsById(esClient, '123')).resolves.toEqual({ + agents: ['agent-a'], + command: 'isolate', + completedAt: '2022-04-30T16:08:47.449Z', + id: '123', + isCompleted: true, + isExpired: false, + logEntries: [ + { + item: { + data: { + '@timestamp': '2022-04-27T16:08:47.449Z', + action_id: '123', + agents: ['agent-a'], + data: { + command: 'isolate', + comment: '5wb6pu6kh2xix5i', + }, + expiration: '2022-04-29T16:08:47.449Z', + input_type: 'endpoint', + type: 'INPUT_ACTION', + user_id: 'elastic', + }, + id: '44d8b915-c69c-4c48-8c86-b57d0bd631d0', + }, + type: 'fleetAction', + }, + { + item: { + data: { + '@timestamp': '2022-04-30T16:08:47.449Z', + action_data: { + command: 'unisolate', + comment: '', + }, + action_id: '123', + agent_id: 'agent-a', + completed_at: '2022-04-30T16:08:47.449Z', + error: '', + started_at: expect.any(String), + }, + id: expect.any(String), + }, + type: 'fleetResponse', + }, + { + item: { + data: { + '@timestamp': '2022-04-30T16:08:47.449Z', + EndpointActions: { + action_id: '123', + completed_at: '2022-04-30T16:08:47.449Z', + data: { + command: 'unisolate', + comment: '', + }, + started_at: expect.any(String), + }, + agent: { + id: 'agent-a', + }, + }, + id: expect.any(String), + }, + type: 'response', + }, + ], + startedAt: '2022-04-27T16:08:47.449Z', + }); + }); + + it('should use expected filters when querying for Action Request', async () => { + await getActionDetailsById(esClient, '123'); + + expect(esClient.search).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + body: { + query: { + bool: { + filter: [ + { term: { action_id: '123' } }, + { term: { input_type: 'endpoint' } }, + { term: { type: 'INPUT_ACTION' } }, + ], + }, + }, + }, + }), + expect.any(Object) + ); + }); + + it('should throw an error if action id does not exist', async () => { + actionRequests.hits.hits = []; + (actionResponses.hits.total as estypes.SearchTotalHits).value = 0; + actionRequests = endpointActionGenerator.toEsSearchResponse([]); + + await expect(getActionDetailsById(esClient, '123')).rejects.toBeInstanceOf(NotFoundError); + }); + + it('should have `isExpired` of `true` if NOT complete and expiration is in the past', async () => { + (actionRequests.hits.hits[0]._source as EndpointAction).expiration = `2021-04-30T16:08:47.449Z`; + actionResponses.hits.hits.pop(); // remove the endpoint response + + await expect(getActionDetailsById(esClient, '123')).resolves.toEqual( + expect.objectContaining({ + isExpired: true, + isCompleted: false, + }) + ); + }); + + it('should have `isExpired` of `false` if complete and expiration is in the past', async () => { + (actionRequests.hits.hits[0]._source as EndpointAction).expiration = `2021-04-30T16:08:47.449Z`; + + await expect(getActionDetailsById(esClient, '123')).resolves.toEqual( + expect.objectContaining({ + isExpired: false, + isCompleted: true, + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts new file mode 100644 index 00000000000000..768c015b53a911 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from '@kbn/core/server'; +import { getActionCompletionInfo, mapToNormalizedActionRequest } from './utils'; +import { + ActionDetails, + ActivityLogAction, + ActivityLogActionResponse, + EndpointAction, + EndpointActionResponse, + EndpointActivityLogAction, + EndpointActivityLogActionResponse, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../../../../common/endpoint/types'; +import { + ACTION_REQUEST_INDICES, + ACTION_RESPONSE_INDICES, + catchAndWrapError, + categorizeActionResults, + categorizeResponseResults, + getUniqueLogData, +} from '../../utils'; +import { EndpointError } from '../../../../common/endpoint/errors'; +import { NotFoundError } from '../../errors'; +import { ACTIONS_SEARCH_PAGE_SIZE } from './constants'; + +export const getActionDetailsById = async ( + esClient: ElasticsearchClient, + actionId: string +): Promise => { + let actionRequestsLogEntries: Array; + + let normalizedActionRequest: ReturnType | undefined; + let actionResponses: Array; + + try { + // Get both the Action Request(s) and action Response(s) + const [actionRequestEsSearchResults, actionResponsesEsSearchResults] = await Promise.all([ + // Get the action request(s) + esClient + .search( + { + index: ACTION_REQUEST_INDICES, + body: { + query: { + bool: { + filter: [ + { term: { action_id: actionId } }, + { term: { input_type: 'endpoint' } }, + { term: { type: 'INPUT_ACTION' } }, + ], + }, + }, + }, + }, + { + ignore: [404], + } + ) + .catch(catchAndWrapError), + + // Get the Action Response(s) + esClient + .search( + { + index: ACTION_RESPONSE_INDICES, + size: ACTIONS_SEARCH_PAGE_SIZE, + body: { + query: { + bool: { + filter: [{ term: { action_id: actionId } }], + }, + }, + }, + }, + { ignore: [404] } + ) + .catch(catchAndWrapError), + ]); + + actionRequestsLogEntries = getUniqueLogData( + categorizeActionResults({ + results: actionRequestEsSearchResults?.hits?.hits ?? [], + }) + ) as Array; + + // Multiple Action records could have been returned, but we only really + // need one since they both hold similar data + const actionDoc = actionRequestsLogEntries[0]?.item.data; + + if (actionDoc) { + normalizedActionRequest = mapToNormalizedActionRequest(actionDoc); + } + + actionResponses = categorizeResponseResults({ + results: actionResponsesEsSearchResults?.hits?.hits ?? [], + }) as Array; + } catch (error) { + throw new EndpointError(error.message, error); + } + + // If action id was not found, error out + if (!normalizedActionRequest) { + throw new NotFoundError(`Action with id '${actionId}' not found.`); + } + + const { isCompleted, completedAt } = getActionCompletionInfo( + normalizedActionRequest.agents, + actionResponses + ); + + const actionDetails: ActionDetails = { + id: actionId, + agents: normalizedActionRequest.agents, + command: normalizedActionRequest.command, + startedAt: normalizedActionRequest.createdAt, + logEntries: [...actionRequestsLogEntries, ...actionResponses], + isCompleted, + completedAt, + isExpired: !isCompleted && normalizedActionRequest.expiration < new Date().toISOString(), + }; + + return actionDetails; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/actions.ts similarity index 96% rename from x-pack/plugins/security_solution/server/endpoint/services/actions.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions/actions.ts index 080ee6e588b031..59060e4e569529 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/actions.ts @@ -9,8 +9,8 @@ import { ElasticsearchClient, Logger } from '@kbn/core/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { TransportResult } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; -import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../common/endpoint/constants'; -import { SecuritySolutionRequestHandlerContext } from '../../types'; +import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../common/endpoint/constants'; +import { SecuritySolutionRequestHandlerContext } from '../../../types'; import { ActivityLog, ActivityLogEntry, @@ -19,7 +19,7 @@ import { EndpointActionResponse, EndpointPendingActions, LogsEndpointActionResponse, -} from '../../../common/endpoint/types'; +} from '../../../../common/endpoint/types'; import { catchAndWrapError, categorizeActionResults, @@ -28,8 +28,9 @@ import { getActionResponsesResult, getTimeSortedData, getUniqueLogData, -} from '../utils'; -import { EndpointMetadataService } from './metadata'; +} from '../../utils'; +import { EndpointMetadataService } from '../metadata'; +import { ACTIONS_SEARCH_PAGE_SIZE } from './constants'; const PENDING_ACTION_RESPONSE_MAX_LAPSED_TIME = 300000; // 300k ms === 5 minutes @@ -194,7 +195,7 @@ export const getPendingActionCounts = async ( .search( { index: AGENT_ACTIONS_INDEX, - size: 10000, + size: ACTIONS_SEARCH_PAGE_SIZE, from: 0, body: { query: { @@ -294,7 +295,7 @@ const hasEndpointResponseDoc = async ({ .search( { index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, - size: 10000, + size: ACTIONS_SEARCH_PAGE_SIZE, body: { query: { bool: { @@ -336,7 +337,7 @@ const fetchActionResponses = async ( .search( { index: AGENT_ACTIONS_RESULTS_INDEX, - size: 10000, + size: ACTIONS_SEARCH_PAGE_SIZE, from: 0, body: { query: { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/constants.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/constants.ts new file mode 100644 index 00000000000000..43907dce85a1bf --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/constants.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * The Page Size to be used when searching against the Actions indexes (both requests and responses) + */ +export const ACTIONS_SEARCH_PAGE_SIZE = 10000; diff --git a/x-pack/plugins/lens/common/expressions/expression_types/index.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts similarity index 65% rename from x-pack/plugins/lens/common/expressions/expression_types/index.ts rename to x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts index 78821e429fa8f1..33d7892891cb8f 100644 --- a/x-pack/plugins/lens/common/expressions/expression_types/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { lensMultitable } from './lens_multitable'; -export type { LensMultitableExpressionTypeDefinition } from './lens_multitable'; +export * from './actions'; +export { getActionDetailsById } from './action_details_by_id'; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.ts new file mode 100644 index 00000000000000..0670b6e3aa4331 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/mocks.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { ElasticsearchClientMock } from '@kbn/core/server/mocks'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator'; +import { + EndpointAction, + EndpointActionResponse, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../../../../common/endpoint/types'; +import { + ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, + ENDPOINT_ACTIONS_INDEX, +} from '../../../../common/endpoint/constants'; + +export const createActionRequestsEsSearchResultsMock = (): estypes.SearchResponse< + EndpointAction | LogsEndpointAction +> => { + const endpointActionGenerator = new EndpointActionGenerator('seed'); + const fleetActionGenerator = new FleetActionGenerator('seed'); + + return endpointActionGenerator.toEsSearchResponse([ + fleetActionGenerator.generateActionEsHit({ + action_id: '123', + agents: ['agent-a'], + '@timestamp': '2022-04-27T16:08:47.449Z', + }), + endpointActionGenerator.generateActionEsHit({ + EndpointActions: { action_id: '123' }, + agent: { id: 'agent-a' }, + '@timestamp': '2022-04-27T16:08:47.449Z', + }), + ]); +}; + +export const createActionResponsesEsSearchResultsMock = (): estypes.SearchResponse< + LogsEndpointActionResponse | EndpointActionResponse +> => { + const endpointActionGenerator = new EndpointActionGenerator('seed'); + const fleetActionGenerator = new FleetActionGenerator('seed'); + + return endpointActionGenerator.toEsSearchResponse< + LogsEndpointActionResponse | EndpointActionResponse + >([ + fleetActionGenerator.generateResponseEsHit({ + action_id: '123', + agent_id: 'agent-a', + error: '', + '@timestamp': '2022-04-30T16:08:47.449Z', + }), + endpointActionGenerator.generateResponseEsHit({ + agent: { id: 'agent-a' }, + EndpointActions: { action_id: '123' }, + '@timestamp': '2022-04-30T16:08:47.449Z', + }), + ]); +}; + +/** + * Applies a mock implementation to the `esClient.search()` method that will return action requests or responses + * depending on what indexes the `.search()` was called with. + * @param esClient + * @param actionRequests + * @param actionResponses + */ +export const applyActionsEsSearchMock = ( + esClient: ElasticsearchClientMock, + actionRequests: estypes.SearchResponse< + EndpointAction | LogsEndpointAction + > = createActionRequestsEsSearchResultsMock(), + actionResponses: estypes.SearchResponse< + LogsEndpointActionResponse | EndpointActionResponse + > = createActionResponsesEsSearchResultsMock() +) => { + const priorSearchMockImplementation = esClient.search.getMockImplementation(); + + esClient.search.mockImplementation(async (...args) => { + const params = args[0] ?? {}; + const indexes = Array.isArray(params.index) ? params.index : [params.index]; + + if (indexes.includes(AGENT_ACTIONS_INDEX) || indexes.includes(ENDPOINT_ACTIONS_INDEX)) { + return actionRequests; + } else if ( + indexes.includes(AGENT_ACTIONS_RESULTS_INDEX) || + indexes.includes(ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN) + ) { + return actionResponses; + } + + if (priorSearchMockImplementation) { + return priorSearchMockImplementation(...args); + } + + return new EndpointActionGenerator().toEsSearchResponse([]); + }); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts new file mode 100644 index 00000000000000..3071c8a417c6ac --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; +import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator'; +import { + getActionCompletionInfo, + isLogsEndpointAction, + isLogsEndpointActionResponse, + mapToNormalizedActionRequest, +} from './utils'; +import type { + ActivityLogActionResponse, + EndpointActivityLogActionResponse, +} from '../../../../common/endpoint/types'; + +describe('When using Actions service utilities', () => { + let fleetActionGenerator: FleetActionGenerator; + let endpointActionGenerator: EndpointActionGenerator; + + beforeEach(() => { + fleetActionGenerator = new FleetActionGenerator('seed'); + endpointActionGenerator = new EndpointActionGenerator('seed'); + }); + + describe('#isLogsEndpointAction()', () => { + it('should return `true` for a `LogsEndpointAction` (endpoint action request)', () => { + expect(isLogsEndpointAction(endpointActionGenerator.generate())).toBe(true); + }); + + it('should return `false` for an `EndpointAction` (fleet action request)', () => { + expect(isLogsEndpointAction(fleetActionGenerator.generate())).toBe(false); + }); + }); + + describe('#isLogsEndpointActionResponse()', () => { + it('should return `true` for a `LogsEndpointActionResponse` (response sent by endpoint)', () => { + expect(isLogsEndpointActionResponse(endpointActionGenerator.generateResponse())).toBe(true); + }); + + it('should return `false` for a `EndpointActionResponse` (response sent by fleet agent)', () => { + expect(isLogsEndpointActionResponse(fleetActionGenerator.generateResponse())).toBe(false); + }); + }); + + describe('#mapToNormalizedActionRequest()', () => { + it('normalizes an `EndpointAction` (those stored in Fleet index)', () => { + expect( + mapToNormalizedActionRequest( + fleetActionGenerator.generate({ + '@timestamp': '2022-04-27T16:08:47.449Z', + }) + ) + ).toEqual({ + agents: ['6e6796b0-af39-4f12-b025-fcb06db499e5'], + command: 'isolate', + comment: 'isolate', + createdAt: '2022-04-27T16:08:47.449Z', + createdBy: 'elastic', + expiration: '2022-04-29T16:08:47.449Z', + id: '90d62689-f72d-4a05-b5e3-500cad0dc366', + type: 'ACTION_REQUEST', + }); + }); + + it('normalizes a `LogsEndpointAction` (those stored in Endpoint index)', () => { + expect( + mapToNormalizedActionRequest( + endpointActionGenerator.generate({ + '@timestamp': '2022-04-27T16:08:47.449Z', + }) + ) + ).toEqual({ + agents: ['90d62689-f72d-4a05-b5e3-500cad0dc366'], + command: 'isolate', + comment: 'isolate', + createdAt: '2022-04-27T16:08:47.449Z', + createdBy: 'Shanel', + expiration: '2022-05-10T16:08:47.449Z', + id: '1d6e6796-b0af-496f-92b0-25fcb06db499', + type: 'ACTION_REQUEST', + }); + }); + }); + + describe('#getAction CompletionInfo()', () => { + const COMPLETED_AT = '2022-05-05T18:53:18.836Z'; + const NOT_COMPLETED_OUTPUT = Object.freeze({ + isCompleted: false, + completed: undefined, + }); + + it('should show complete `false` if no action ids', () => { + expect(getActionCompletionInfo([], [])).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should show complete as `false` if no responses', () => { + expect(getActionCompletionInfo(['123'], [])).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should show complete as `false` if no Endpoint response', () => { + expect( + getActionCompletionInfo( + ['123'], + [ + fleetActionGenerator.generateActivityLogActionResponse({ + item: { data: { action_id: '123' } }, + }), + ] + ) + ).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should show complete as `true` with completion date if Endpoint Response received', () => { + expect( + getActionCompletionInfo( + ['123'], + [ + endpointActionGenerator.generateActivityLogActionResponse({ + item: { + data: { + '@timestamp': COMPLETED_AT, + agent: { id: '123' }, + EndpointActions: { completed_at: COMPLETED_AT }, + }, + }, + }), + ] + ) + ).toEqual({ isCompleted: true, completedAt: COMPLETED_AT }); + }); + + describe('with multiple agent ids', () => { + let agentIds: string[]; + let action123Responses: Array; + let action456Responses: Array; + let action789Responses: Array; + + beforeEach(() => { + agentIds = ['123', '456', '789']; + action123Responses = [ + fleetActionGenerator.generateActivityLogActionResponse({ + item: { data: { agent_id: '123', error: '' } }, + }), + endpointActionGenerator.generateActivityLogActionResponse({ + item: { + data: { + '@timestamp': '2022-01-05T19:27:23.816Z', + agent: { id: '123' }, + EndpointActions: { completed_at: '2022-01-05T19:27:23.816Z' }, + }, + }, + }), + ]; + + action456Responses = [ + fleetActionGenerator.generateActivityLogActionResponse({ + item: { data: { agent_id: '456', error: '' } }, + }), + endpointActionGenerator.generateActivityLogActionResponse({ + item: { + data: { + '@timestamp': COMPLETED_AT, + agent: { id: '456' }, + EndpointActions: { completed_at: COMPLETED_AT }, + }, + }, + }), + ]; + + action789Responses = [ + fleetActionGenerator.generateActivityLogActionResponse({ + item: { data: { agent_id: '789', error: '' } }, + }), + endpointActionGenerator.generateActivityLogActionResponse({ + item: { + data: { + '@timestamp': '2022-03-05T19:27:23.816Z', + agent: { id: '789' }, + EndpointActions: { completed_at: '2022-03-05T19:27:23.816Z' }, + }, + }, + }), + ]; + }); + + it('should show complete as `false` if no responses', () => { + expect(getActionCompletionInfo(agentIds, [])).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should complete as `false` if at least one agent id is has not received a response', () => { + expect( + getActionCompletionInfo(agentIds, [ + ...action123Responses, + + // Action id: 456 === Not complete (only fleet response) + action456Responses[0], + + ...action789Responses, + ]) + ).toEqual(NOT_COMPLETED_OUTPUT); + }); + + it('should show complete as `true` if all agent response were received', () => { + expect( + getActionCompletionInfo(agentIds, [ + ...action123Responses, + ...action456Responses, + ...action789Responses, + ]) + ).toEqual({ isCompleted: true, completedAt: COMPLETED_AT }); + }); + + it('should complete as `true` if one agent only received a fleet response with error on it', () => { + action456Responses[0].item.data.error = 'something is no good'; + action456Responses[0].item.data['@timestamp'] = '2022-05-06T12:50:19.747Z'; + + expect( + getActionCompletionInfo(agentIds, [ + ...action123Responses, + + // Action id: 456 === is complete with only a fleet response that has `error` + action456Responses[0], + + ...action789Responses, + ]) + ).toEqual({ isCompleted: true, completedAt: '2022-05-06T12:50:19.747Z' }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts new file mode 100644 index 00000000000000..cf4c2ba6a718d5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ActivityLogActionResponse, + EndpointAction, + EndpointActionResponse, + EndpointActivityLogActionResponse, + LogsEndpointAction, + LogsEndpointActionResponse, +} from '../../../../common/endpoint/types'; + +/** + * Type guard to check if a given Action is in the shape of the Endpoint Action. + * @param item + */ +export const isLogsEndpointAction = ( + item: LogsEndpointAction | EndpointAction +): item is LogsEndpointAction => { + return 'EndpointActions' in item && 'user' in item && 'agent' in item && '@timestamp' in item; +}; + +/** + * Type guard to track if a given action response is in the shape of the Endpoint Action Response (from the endpoint index) + * @param item + */ +export const isLogsEndpointActionResponse = ( + item: EndpointActionResponse | LogsEndpointActionResponse +): item is LogsEndpointActionResponse => { + return 'EndpointActions' in item && 'agent' in item; +}; + +interface NormalizedActionRequest { + id: string; + type: 'ACTION_REQUEST'; + expiration: string; + agents: string[]; + createdBy: string; + createdAt: string; + command: string; + comment?: string; +} + +/** + * Given an Action record - either a fleet action or an endpoint action - this utility + * will return a normalized data structure based on those two types, which + * will avoid us having to either cast or do type guards against the two different + * types of action request. + */ +export const mapToNormalizedActionRequest = ( + actionRequest: EndpointAction | LogsEndpointAction +): NormalizedActionRequest => { + if (isLogsEndpointAction(actionRequest)) { + return { + agents: Array.isArray(actionRequest.agent.id) + ? actionRequest.agent.id + : [actionRequest.agent.id], + command: actionRequest.EndpointActions.data.command, + comment: actionRequest.EndpointActions.data.command, + type: 'ACTION_REQUEST', + id: actionRequest.EndpointActions.action_id, + expiration: actionRequest.EndpointActions.expiration, + createdBy: actionRequest.user.id, + createdAt: actionRequest['@timestamp'], + }; + } + + // Else, it's a Fleet Endpoint Action record + return { + agents: actionRequest.agents, + command: actionRequest.data.command, + comment: actionRequest.data.command, + type: 'ACTION_REQUEST', + id: actionRequest.action_id, + expiration: actionRequest.expiration, + createdBy: actionRequest.user_id, + createdAt: actionRequest['@timestamp'], + }; +}; + +interface ActionCompletionInfo { + isCompleted: boolean; + completedAt: undefined | string; +} + +export const getActionCompletionInfo = ( + /** List of agents that the action was sent to */ + agentIds: string[], + /** List of action Log responses received for the action */ + actionResponses: Array +): ActionCompletionInfo => { + const completedInfo: ActionCompletionInfo = { + isCompleted: Boolean(agentIds.length), + completedAt: undefined, + }; + + const responsesByAgentId = mapActionResponsesByAgentId(actionResponses); + + for (const agentId of agentIds) { + if (!responsesByAgentId[agentId] || !responsesByAgentId[agentId].isCompleted) { + completedInfo.isCompleted = false; + break; + } + } + + // If completed, then get the completed at date + if (completedInfo.isCompleted) { + for (const normalizedAgentResponse of Object.values(responsesByAgentId)) { + if ( + !completedInfo.completedAt || + completedInfo.completedAt < (normalizedAgentResponse.completedAt ?? '') + ) { + completedInfo.completedAt = normalizedAgentResponse.completedAt; + } + } + } + + return completedInfo; +}; + +interface NormalizedAgentActionResponse { + isCompleted: boolean; + completedAt: undefined | string; + fleetResponse: undefined | ActivityLogActionResponse; + endpointResponse: undefined | EndpointActivityLogActionResponse; +} + +type ActionResponseByAgentId = Record; + +/** + * Given a list of Action Responses, it will return a Map where keys are the Agent ID and + * value is a object having information about the action response's associated with that agent id + * @param actionResponses + */ +const mapActionResponsesByAgentId = ( + actionResponses: Array +): ActionResponseByAgentId => { + const response: ActionResponseByAgentId = {}; + + for (const actionResponse of actionResponses) { + if (actionResponse.type === 'fleetResponse' || actionResponse.type === 'response') { + const agentId = getAgentIdFromActionResponse(actionResponse); + let thisAgentActionResponses = response[agentId]; + + if (!thisAgentActionResponses) { + response[agentId] = { + isCompleted: false, + completedAt: undefined, + fleetResponse: undefined, + endpointResponse: undefined, + }; + + thisAgentActionResponses = response[agentId]; + } + + if (actionResponse.type === 'fleetResponse') { + thisAgentActionResponses.fleetResponse = actionResponse; + } else { + thisAgentActionResponses.endpointResponse = actionResponse; + } + + thisAgentActionResponses.isCompleted = + // Action is complete if an Endpoint Action Response was received + Boolean(thisAgentActionResponses.endpointResponse) || + // OR: + // If we did not have an endpoint response and the Fleet response has `error`, then + // action is complete. Elastic Agent was unable to deliver the action request to the + // endpoint, so we are unlikely to ever receive an Endpoint Response. + Boolean(thisAgentActionResponses.fleetResponse?.item.data.error); + + if (thisAgentActionResponses.isCompleted) { + if (thisAgentActionResponses.endpointResponse) { + thisAgentActionResponses.completedAt = + thisAgentActionResponses.endpointResponse?.item.data['@timestamp']; + } else if ( + thisAgentActionResponses.fleetResponse && + thisAgentActionResponses.fleetResponse?.item.data.error + ) { + // Check if perhaps the Fleet action response returned an error, in which case, the Fleet Agent + // failed to deliver the Action to the Endpoint. If that's the case, we are not going to get + // a Response from endpoint, thus mark the Action as completed and use the Fleet Message's + // timestamp for the complete data/time. + thisAgentActionResponses.isCompleted = true; + thisAgentActionResponses.completedAt = + thisAgentActionResponses.fleetResponse?.item.data['@timestamp']; + } + } + } + } + + return response; +}; + +/** + * Given an Action response, this will return the Agent ID for that action response. + * @param actionResponse + */ +const getAgentIdFromActionResponse = ( + actionResponse: ActivityLogActionResponse | EndpointActivityLogActionResponse +): string => { + const responseData = actionResponse.item.data; + + if (isLogsEndpointActionResponse(responseData)) { + return Array.isArray(responseData.agent.id) ? responseData.agent.id[0] : responseData.agent.id; + } + + return responseData.agent_id; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts index 3a1e25c32b6837..dc326f4fa4631f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts @@ -30,11 +30,14 @@ import { LogsEndpointActionResponse, ActivityLogEntry, } from '../../../common/endpoint/types'; -import { doesLogsEndpointActionsIndexExist } from '.'; +import { doesLogsEndpointActionsIndexExist } from './yes_no_data_stream'; -const actionsIndices = [AGENT_ACTIONS_INDEX, ENDPOINT_ACTIONS_INDEX]; +export const ACTION_REQUEST_INDICES = [AGENT_ACTIONS_INDEX, ENDPOINT_ACTIONS_INDEX]; // search all responses indices irrelevant of namespace -const responseIndices = [AGENT_ACTIONS_RESULTS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN]; +export const ACTION_RESPONSE_INDICES = [ + AGENT_ACTIONS_RESULTS_INDEX, + ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN, +]; export const logsEndpointActionsRegex = new RegExp(`(^\.ds-\.logs-endpoint\.actions-default-).+`); // matches index names like .ds-.logs-endpoint.action.responses-name_space---suffix-2022.01.25-000001 export const logsEndpointResponsesRegex = new RegExp( @@ -173,7 +176,7 @@ export const getActionRequestsResult = async ({ }); const actionsSearchQuery: SearchRequest = { - index: hasLogsEndpointActionsIndex ? actionsIndices : AGENT_ACTIONS_INDEX, + index: hasLogsEndpointActionsIndex ? ACTION_REQUEST_INDICES : AGENT_ACTIONS_INDEX, size, from, body: { @@ -238,7 +241,9 @@ export const getActionResponsesResult = async ({ }); const responsesSearchQuery: SearchRequest = { - index: hasLogsEndpointActionResponsesIndex ? responseIndices : AGENT_ACTIONS_RESULTS_INDEX, + index: hasLogsEndpointActionResponsesIndex + ? ACTION_RESPONSE_INDICES + : AGENT_ACTIONS_RESULTS_INDEX, size: 1000, body: { query: { diff --git a/x-pack/plugins/session_view/public/components/process_tree_load_more_button/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_load_more_button/index.tsx index fd5bc3fa13e86b..942d1606564b79 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_load_more_button/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_load_more_button/index.tsx @@ -36,7 +36,7 @@ export const ProcessTreeLoadMoreButton = ({ isLoading={isFetching} > {text} - {eventsRemaining !== 0 && ( + {eventsRemaining > 0 && ( { expect(checkbox).toHaveAttribute('checked'); }); - it('should dispatch remove column action on field unchecked', () => { - const result = render( - - - - ); + describe('selection', () => { + it('should dispatch remove column action on field unchecked', () => { + const result = render( + + + + ); - result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); + result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith( - tGridActions.removeColumn({ id: timelineId, columnId: timestampFieldId }) - ); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.removeColumn({ id: timelineId, columnId: timestampFieldId }) + ); + }); + + it('should dispatch upsert column action on field checked', () => { + const result = render( + + + + ); + + result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith( + tGridActions.upsertColumn({ + id: timelineId, + column: { + columnHeaderType: defaultColumnHeaderType, + id: timestampFieldId, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + }, + index: 1, + }) + ); + }); }); - it('should dispatch upsert column action on field checked', () => { - const result = render( - + describe('pagination', () => { + const isAtFirstPage = (result: RenderResult) => + result.getByTestId('pagination-button-0').classList.contains('euiPaginationButton-isActive'); + + const changePage = (result: RenderResult) => { + result.getByTestId('pagination-button-1').click(); + }; + + const defaultPaginationProps: FieldTableProps = { + ...defaultProps, + filteredBrowserFields: mockBrowserFields, + }; + + it('should paginate on page clicked', () => { + const result = render( + + + + ); + + expect(isAtFirstPage(result)).toBeTruthy(); + + changePage(result); + + expect(isAtFirstPage(result)).toBeFalsy(); + }); + + it('should not reset on field checked', () => { + const result = render( + + + + ); + + changePage(result); + + result.getAllByRole('checkbox').at(0)?.click(); + expect(mockDispatch).toHaveBeenCalled(); // assert some field has been selected + + expect(isAtFirstPage(result)).toBeFalsy(); + }); + + it('should reset on filter change', () => { + const result = render( , + { wrapper: TestProviders } + ); + + changePage(result); + expect(isAtFirstPage(result)).toBeFalsy(); + + result.rerender( + - - ); + ); - result.getByTestId(`field-${timestampFieldId}-checkbox`).click(); - - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith( - tGridActions.upsertColumn({ - id: timelineId, - column: { - columnHeaderType: defaultColumnHeaderType, - id: timestampFieldId, - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, - }, - index: 1, - }) - ); + expect(isAtFirstPage(result)).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx index 684b09d0395ab9..4f62cdd2468715 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { EuiInMemoryTable } from '@elastic/eui'; +import { EuiInMemoryTable, Pagination, Direction } from '@elastic/eui'; import { useDispatch } from 'react-redux'; import { BrowserFields, ColumnHeaderOptions } from '../../../../../common'; import { getColumnHeader, getFieldColumns, getFieldItems, isActionsColumn } from './field_items'; @@ -16,6 +16,11 @@ import { tGridActions } from '../../../../store/t_grid'; import type { GetFieldTableColumns } from '../../../../../common/types/fields_browser'; import { FieldTableHeader } from './field_table_header'; +const DEFAULT_SORTING: { field: string; direction: Direction } = { + field: '', + direction: 'asc', +} as const; + export interface FieldTableProps { timelineId: string; columnHeaders: ColumnHeaderOptions[]; @@ -69,6 +74,12 @@ const FieldTableComponent: React.FC = ({ timelineId, onHide, }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [sortField, setSortField] = useState(DEFAULT_SORTING.field); + const [sortDirection, setSortDirection] = useState(DEFAULT_SORTING.direction); + const dispatch = useDispatch(); const fieldItems = useMemo( @@ -103,6 +114,51 @@ const FieldTableComponent: React.FC = ({ [columnHeaders, dispatch, timelineId] ); + /** + * Pagination controls + */ + const pagination: Pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: fieldItems.length, + pageSizeOptions: [10, 25, 50], + }), + [fieldItems.length, pageIndex, pageSize] + ); + + useEffect(() => { + // Resets the pagination when some filter has changed, consequently, the number of fields is different + setPageIndex(0); + }, [fieldItems.length]); + + /** + * Sorting controls + */ + const sorting = useMemo( + () => ({ + sort: { + field: sortField, + direction: sortDirection, + }, + }), + [sortDirection, sortField] + ); + + const onTableChange = useCallback(({ page, sort = DEFAULT_SORTING }) => { + const { index, size } = page; + const { field, direction } = sort; + + setPageIndex(index); + setPageSize(size); + + setSortField(field); + setSortDirection(direction); + }, []); + + /** + * Process columns + */ const columns = useMemo( () => getFieldColumns({ highlight: searchInput, onToggleColumn, getFieldTableColumns, onHide }), [onToggleColumn, searchInput, getFieldTableColumns, onHide] @@ -124,9 +180,10 @@ const FieldTableComponent: React.FC = ({ items={fieldItems} itemId="name" columns={columns} - pagination={true} - sorting={true} + pagination={pagination} + sorting={sorting} hasActions={hasActions} + onChange={onTableChange} compressed /> diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 704a6fe19ac172..513f83e23f1774 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -365,7 +365,6 @@ "xpack.lens.functions.counterRate.args.outputColumnNameHelpText": "Nom de la colonne dans laquelle le taux de compteur résultant sera stocké", "xpack.lens.functions.counterRate.help": "Calcule le taux de compteur d'une colonne dans un tableau de données", "xpack.lens.functions.lastValue.missingSortField": "Cette vue de données ne contient aucun champ de date.", - "xpack.lens.functions.mergeTables.help": "Aide pour fusionner n'importe quel nombre de tableaux Kibana en un tableau unique et l'exposer via un adaptateur d'inspecteur", "xpack.lens.functions.renameColumns.help": "Aide pour renommer les colonnes d'un tableau de données", "xpack.lens.functions.renameColumns.idMap.help": "Un objet encodé JSON dans lequel les clés sont les anciens ID de colonne et les valeurs sont les nouveaux ID correspondants. Tous les autres ID de colonne sont conservés.", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "L'ID de colonne de date {columnId} n'existe pas.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8fe72dfd4a8709..df42895ac2cbc2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -367,7 +367,6 @@ "xpack.lens.functions.counterRate.args.outputColumnNameHelpText": "結果のカウンターレートを格納する列の名前", "xpack.lens.functions.counterRate.help": "データテーブルの列のカウンターレートを計算します", "xpack.lens.functions.lastValue.missingSortField": "このデータビューには日付フィールドが含まれていません", - "xpack.lens.functions.mergeTables.help": "任意の数の Kibana 表を 1 つの表に結合し、インスペクターアダプター経由で公開するヘルパー", "xpack.lens.functions.renameColumns.help": "データベースの列の名前の変更をアシストします", "xpack.lens.functions.renameColumns.idMap.help": "キーが古い列 ID で値が対応する新しい列 ID となるように JSON エンコーディングされたオブジェクトです。他の列 ID はすべてのそのままです。", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定した dateColumnId {columnId} は存在しません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6509cfadf3b997..6783295921119e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -372,7 +372,6 @@ "xpack.lens.functions.counterRate.args.outputColumnNameHelpText": "要存储结果计数率的列名称", "xpack.lens.functions.counterRate.help": "在数据表中计算列的计数率", "xpack.lens.functions.lastValue.missingSortField": "此数据视图不包含任何日期字段", - "xpack.lens.functions.mergeTables.help": "将任意数目的 kibana 表合并到单个表中并通过检查器适配器将其开放的助手", "xpack.lens.functions.renameColumns.help": "用于重命名数据表列的助手", "xpack.lens.functions.renameColumns.idMap.help": "旧列 ID 为键且相应新列 ID 为值的 JSON 编码对象。所有其他列 ID 都将保留。", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定的 dateColumnId {columnId} 不存在。", diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index bc382e5d733dda..529e04c184740c 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -727,6 +727,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { category: response.body.rule_type_id, license: 'basic', ruleset: 'alertsFixture', + name: 'abc', }, consumer: 'alertsFixture', numActiveAlerts: 0, diff --git a/x-pack/test/functional/apps/discover/reporting.ts b/x-pack/test/functional/apps/discover/reporting.ts index 55282dd143b7f6..d1eb2e7e03c270 100644 --- a/x-pack/test/functional/apps/discover/reporting.ts +++ b/x-pack/test/functional/apps/discover/reporting.ts @@ -270,6 +270,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await setupPage(); }); + afterEach(async () => { + await PageObjects.reporting.checkForReportingToasts(); + }); + it('generates a report with data', async () => { await PageObjects.discover.loadSavedSearch('Ecommerce Data'); await retry.try(async () => { diff --git a/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts b/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts index dec72008d6f04b..6b772c8d13c05e 100644 --- a/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts @@ -13,7 +13,8 @@ export default function ({ getPageObjects }: FtrProviderContext) { const xyChartContainer = 'xyVisChart'; describe('lens drag and drop tests', () => { - describe('basic drag and drop', () => { + // FLAKY: https://github.com/elastic/kibana/issues/108352 + describe.skip('basic drag and drop', () => { it('should construct the basic split xy chart', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); diff --git a/x-pack/test/functional/apps/lens/group3/gauge.ts b/x-pack/test/functional/apps/lens/group3/gauge.ts index 9cbcbb606b4234..ea029793ddfc04 100644 --- a/x-pack/test/functional/apps/lens/group3/gauge.ts +++ b/x-pack/test/functional/apps/lens/group3/gauge.ts @@ -48,6 +48,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should reflect edits for gauge', async () => { + await PageObjects.lens.switchToVisualization('horizontalBullet', 'gauge'); + await PageObjects.lens.waitForVisualization('gaugeChart'); await PageObjects.lens.configureDimension({ dimension: 'lnsGauge_metricDimensionPanel > lns-dimensionTrigger', operation: 'count', diff --git a/yarn.lock b/yarn.lock index ea3dec235c93e1..43d8d6ce5a120e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11008,10 +11008,10 @@ core-js@^2.4.0, core-js@^2.5.0, core-js@^2.6.9: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== -core-js@^3.0.4, core-js@^3.21.1, core-js@^3.6.5, core-js@^3.8.2, core-js@^3.8.3: - version "3.21.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.1.tgz#f2e0ddc1fc43da6f904706e8e955bc19d06a0d94" - integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig== +core-js@^3.0.4, core-js@^3.22.4, core-js@^3.6.5, core-js@^3.8.2, core-js@^3.8.3: + version "3.22.4" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.22.4.tgz#f4b3f108d45736935aa028444a69397e40d8c531" + integrity sha512-1uLykR+iOfYja+6Jn/57743gc9n73EWiOnSJJ4ba3B4fOEYDBv25MagmEZBxTp5cWq4b/KPx/l77zgsp28ju4w== core-util-is@1.0.2, core-util-is@^1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -11740,9 +11740,9 @@ d3-color@1, d3-color@^1.0.3: integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== "d3-color@1 - 3", d3-color@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.0.1.tgz#03316e595955d1fcd39d9f3610ad41bb90194d0a" - integrity sha512-6/SlHkDOBLyQSJ1j1Ghs82OIUXpKWlR0hCsw0XrLSQhuUPuCSmLQ1QPH98vpnQxMUQM2/gfAkUEWsupVpd9JGw== + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== d3-contour@^1.1.0: version "1.3.2"