diff --git a/docs/maps/vector-layer.asciidoc b/docs/maps/vector-layer.asciidoc index 7191197c27dbe..f70e4d59796cc 100644 --- a/docs/maps/vector-layer.asciidoc +++ b/docs/maps/vector-layer.asciidoc @@ -27,9 +27,9 @@ Results exceeding `index.max_result_window` are not displayed. * *Show clusters when results exceed 10,000* When results exceed `index.max_result_window`, the layer uses {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[GeoTile grid aggregation] to group your documents into clusters and displays metrics for each cluster. When results are less then `index.max_result_window`, the layer displays features from individual documents. -* *Use vector tiles.* Vector tiles partition your map into 6 to 8 tiles. +* *Use vector tiles.* Vector tiles partition your map into tiles. Each tile request is limited to the `index.max_result_window` index setting. -Tiles exceeding `index.max_result_window` have a visual indicator when there are too many features to display. +When a tile exceeds `index.max_result_window`, results exceeding `index.max_result_window` are not contained in the tile and a dashed rectangle outlining the bounding box containing all geo values within the tile is displayed. *EMS Boundaries*:: Administrative boundaries from https://www.elastic.co/elastic-maps-service[Elastic Maps Service]. diff --git a/package.json b/package.json index 3df7b65315a85..d9dd4912481b9 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary.21", "@elastic/ems-client": "7.16.0", - "@elastic/eui": "39.1.1", + "@elastic/eui": "40.0.0", "@elastic/filesaver": "1.1.2", "@elastic/maki": "6.3.0", "@elastic/node-crypto": "1.2.1", @@ -166,7 +166,6 @@ "@mapbox/geojson-rewind": "^0.5.0", "@mapbox/mapbox-gl-draw": "1.3.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", - "@mapbox/vector-tile": "1.3.1", "@reduxjs/toolkit": "^1.6.1", "@slack/webhook": "^5.0.4", "@turf/along": "6.0.1", @@ -460,6 +459,7 @@ "@kbn/test": "link:bazel-bin/packages/kbn-test", "@kbn/test-subj-selector": "link:bazel-bin/packages/kbn-test-subj-selector", "@loaders.gl/polyfills": "^2.3.5", + "@mapbox/vector-tile": "1.3.1", "@microsoft/api-documenter": "7.7.2", "@microsoft/api-extractor": "7.7.0", "@octokit/rest": "^16.35.0", @@ -743,7 +743,7 @@ "jsondiffpatch": "0.4.1", "license-checker": "^16.0.0", "listr": "^0.14.1", - "lmdb-store": "^1.6.8", + "lmdb-store": "^1.6.11", "marge": "^1.0.1", "micromatch": "3.1.10", "minimist": "^1.2.5", diff --git a/packages/elastic-apm-generator/BUILD.bazel b/packages/elastic-apm-generator/BUILD.bazel index 6b46b2b9181e5..396c27b3a4c89 100644 --- a/packages/elastic-apm-generator/BUILD.bazel +++ b/packages/elastic-apm-generator/BUILD.bazel @@ -25,6 +25,7 @@ NPM_MODULE_EXTRA_FILES = [ ] RUNTIME_DEPS = [ + "//packages/elastic-datemath", "@npm//@elastic/elasticsearch", "@npm//lodash", "@npm//moment", @@ -36,6 +37,7 @@ RUNTIME_DEPS = [ ] TYPES_DEPS = [ + "//packages/elastic-datemath", "@npm//@elastic/elasticsearch", "@npm//moment", "@npm//p-limit", diff --git a/packages/elastic-apm-generator/README.md b/packages/elastic-apm-generator/README.md index e43187a8155d3..b442c0ec23ee0 100644 --- a/packages/elastic-apm-generator/README.md +++ b/packages/elastic-apm-generator/README.md @@ -11,7 +11,7 @@ This section assumes that you've installed Kibana's dependencies by running `yar This library can currently be used in two ways: - Imported as a Node.js module, for instance to be used in Kibana's functional test suite. -- With a command line interface, to index data based on some example scenarios. +- With a command line interface, to index data based on a specified scenario. ### Using the Node.js module @@ -32,7 +32,7 @@ const instance = service('synth-go', 'production', 'go') .instance('instance-a'); const from = new Date('2021-01-01T12:00:00.000Z').getTime(); -const to = new Date('2021-01-01T12:00:00.000Z').getTime() - 1; +const to = new Date('2021-01-01T12:00:00.000Z').getTime(); const traceEvents = timerange(from, to) .interval('1m') @@ -82,12 +82,26 @@ const esEvents = toElasticsearchOutput([ ### CLI -Via the CLI, you can upload examples. The supported examples are listed in `src/lib/es.ts`. A `--target` option that specifies the Elasticsearch URL should be defined when running the `example` command. Here's an example: +Via the CLI, you can upload scenarios, either using a fixed time range or continuously generating data. Some examples are available in in `src/scripts/examples`. Here's an example for live data: -`$ node packages/elastic-apm-generator/src/scripts/es.js example simple-trace --target=http://admin:changeme@localhost:9200` +`$ node packages/elastic-apm-generator/src/scripts/run packages/elastic-apm-generator/src/examples/01_simple_trace.ts --target=http://admin:changeme@localhost:9200 --live` + +For a fixed time window: +`$ node packages/elastic-apm-generator/src/scripts/run packages/elastic-apm-generator/src/examples/01_simple_trace.ts --target=http://admin:changeme@localhost:9200 --from=now-24h --to=now` + +The script will try to automatically find bootstrapped APM indices. __If these indices do not exist, the script will exit with an error. It will not bootstrap the indices itself.__ The following options are supported: -- `to`: the end of the time range, in ISO format. By default, the current time will be used. -- `from`: the start of the time range, in ISO format. By default, `to` minus 15 minutes will be used. -- `apm-server-version`: the version used in the index names bootstrapped by APM Server, e.g. `7.16.0`. __If these indices do not exist, the script will exit with an error. It will not bootstrap the indices itself.__ +| Option | Description | Default | +| -------------- | ------------------------------------------------------- | ------------ | +| `--from` | The start of the time window. | `now - 15m` | +| `--to` | The end of the time window. | `now` | +| `--live` | Continously ingest data | `false` | +| `--bucketSize` | Size of bucket for which to generate data. | `15m` | +| `--clean` | Clean APM indices before indexing new data. | `false` | +| `--interval` | The interval at which to index data. | `10s` | +| `--logLevel` | Log level. | `info` | +| `--lookback` | The lookback window for which data should be generated. | `15m` | +| `--target` | Elasticsearch target, including username/password. | **Required** | +| `--workers` | Amount of simultaneously connected ES clients. | `1` | diff --git a/packages/elastic-apm-generator/src/.eslintrc.js b/packages/elastic-apm-generator/src/.eslintrc.js new file mode 100644 index 0000000000000..2e3eef95f4bf3 --- /dev/null +++ b/packages/elastic-apm-generator/src/.eslintrc.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + rules: { + 'import/no-default-export': 'off', + }, +}; diff --git a/packages/elastic-apm-generator/src/lib/interval.ts b/packages/elastic-apm-generator/src/lib/interval.ts index f13d54fd7415e..bafd1a06c5348 100644 --- a/packages/elastic-apm-generator/src/lib/interval.ts +++ b/packages/elastic-apm-generator/src/lib/interval.ts @@ -21,7 +21,7 @@ export class Interval { throw new Error('Failed to parse interval'); } const timestamps: number[] = []; - while (now <= this.to) { + while (now < this.to) { timestamps.push(...new Array(rate).fill(now)); now = moment(now) .add(Number(args[1]), args[2] as any) diff --git a/packages/elastic-apm-generator/src/lib/output/to_elasticsearch_output.ts b/packages/elastic-apm-generator/src/lib/output/to_elasticsearch_output.ts index d90ce8e01f83d..31f3e8c8ed270 100644 --- a/packages/elastic-apm-generator/src/lib/output/to_elasticsearch_output.ts +++ b/packages/elastic-apm-generator/src/lib/output/to_elasticsearch_output.ts @@ -10,7 +10,25 @@ import { set } from 'lodash'; import { getObserverDefaults } from '../..'; import { Fields } from '../entity'; -export function toElasticsearchOutput(events: Fields[], versionOverride?: string) { +export interface ElasticsearchOutput { + _index: string; + _source: unknown; +} + +export interface ElasticsearchOutputWriteTargets { + transaction: string; + span: string; + error: string; + metric: string; +} + +export function toElasticsearchOutput({ + events, + writeTargets, +}: { + events: Fields[]; + writeTargets: ElasticsearchOutputWriteTargets; +}): ElasticsearchOutput[] { return events.map((event) => { const values = { ...event, @@ -29,7 +47,7 @@ export function toElasticsearchOutput(events: Fields[], versionOverride?: string set(document, key, val); } return { - _index: `apm-${versionOverride || values['observer.version']}-${values['processor.event']}`, + _index: writeTargets[event['processor.event'] as keyof ElasticsearchOutputWriteTargets], _source: document, }; }); diff --git a/packages/elastic-apm-generator/src/scripts/es.ts b/packages/elastic-apm-generator/src/scripts/es.ts deleted file mode 100644 index d023ef7172892..0000000000000 --- a/packages/elastic-apm-generator/src/scripts/es.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { inspect } from 'util'; -import { Client } from '@elastic/elasticsearch'; -import { chunk } from 'lodash'; -import pLimit from 'p-limit'; -import yargs from 'yargs/yargs'; -import { toElasticsearchOutput } from '..'; -import { simpleTrace } from './examples/01_simple_trace'; - -yargs(process.argv.slice(2)) - .command( - 'example', - 'run an example scenario', - (y) => { - return y - .positional('scenario', { - describe: 'scenario to run', - choices: ['simple-trace'], - demandOption: true, - }) - .option('target', { - describe: 'elasticsearch target, including username/password', - }) - .option('from', { describe: 'start of timerange' }) - .option('to', { describe: 'end of timerange' }) - .option('workers', { - default: 1, - describe: 'number of concurrently connected ES clients', - }) - .option('apm-server-version', { - describe: 'APM Server version override', - }) - .demandOption('target'); - }, - (argv) => { - let events: any[] = []; - const toDateString = (argv.to as string | undefined) || new Date().toISOString(); - const fromDateString = - (argv.from as string | undefined) || - new Date(new Date(toDateString).getTime() - 15 * 60 * 1000).toISOString(); - - const to = new Date(toDateString).getTime(); - const from = new Date(fromDateString).getTime(); - - switch (argv._[1]) { - case 'simple-trace': - events = simpleTrace(from, to); - break; - } - - const docs = toElasticsearchOutput(events, argv['apm-server-version'] as string); - - const client = new Client({ - node: argv.target as string, - }); - - const fn = pLimit(argv.workers); - - const batches = chunk(docs, 1000); - - // eslint-disable-next-line no-console - console.log( - 'Uploading', - docs.length, - 'docs in', - batches.length, - 'batches', - 'from', - fromDateString, - 'to', - toDateString - ); - - Promise.all( - batches.map((batch) => - fn(() => { - return client.bulk({ - require_alias: true, - body: batch.flatMap((doc) => { - return [{ index: { _index: doc._index } }, doc._source]; - }), - }); - }) - ) - ) - .then((results) => { - const errors = results - .flatMap((result) => result.body.items) - .filter((item) => !!item.index?.error) - .map((item) => item.index?.error); - - if (errors.length) { - // eslint-disable-next-line no-console - console.error(inspect(errors.slice(0, 10), { depth: null })); - throw new Error('Failed to upload some items'); - } - process.exit(); - }) - .catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - process.exit(1); - }); - } - ) - .parse(); diff --git a/packages/elastic-apm-generator/src/scripts/examples/01_simple_trace.ts b/packages/elastic-apm-generator/src/scripts/examples/01_simple_trace.ts index f6aad154532c2..6b857391b4f96 100644 --- a/packages/elastic-apm-generator/src/scripts/examples/01_simple_trace.ts +++ b/packages/elastic-apm-generator/src/scripts/examples/01_simple_trace.ts @@ -9,12 +9,12 @@ import { service, timerange, getTransactionMetrics, getSpanDestinationMetrics } from '../..'; import { getBreakdownMetrics } from '../../lib/utils/get_breakdown_metrics'; -export function simpleTrace(from: number, to: number) { +export default function ({ from, to }: { from: number; to: number }) { const instance = service('opbeans-go', 'production', 'go').instance('instance'); const range = timerange(from, to); - const transactionName = '240rpm/60% 1000ms'; + const transactionName = '240rpm/75% 1000ms'; const successfulTraceEvents = range .interval('1s') diff --git a/packages/elastic-apm-generator/src/scripts/es.js b/packages/elastic-apm-generator/src/scripts/run.js similarity index 96% rename from packages/elastic-apm-generator/src/scripts/es.js rename to packages/elastic-apm-generator/src/scripts/run.js index 9f99a5d19b8f8..426b247b6b623 100644 --- a/packages/elastic-apm-generator/src/scripts/es.js +++ b/packages/elastic-apm-generator/src/scripts/run.js @@ -12,4 +12,4 @@ require('@babel/register')({ presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], }); -require('./es.ts'); +require('./run.ts'); diff --git a/packages/elastic-apm-generator/src/scripts/run.ts b/packages/elastic-apm-generator/src/scripts/run.ts new file mode 100644 index 0000000000000..ad453ac96ff10 --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/run.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 datemath from '@elastic/datemath'; +import yargs from 'yargs/yargs'; +import { cleanWriteTargets } from './utils/clean_write_targets'; +import { + bucketSizeOption, + cleanOption, + fileOption, + intervalOption, + targetOption, + workerOption, + logLevelOption, +} from './utils/common_options'; +import { intervalToMs } from './utils/interval_to_ms'; +import { getCommonResources } from './utils/get_common_resources'; +import { startHistoricalDataUpload } from './utils/start_historical_data_upload'; +import { startLiveDataUpload } from './utils/start_live_data_upload'; + +yargs(process.argv.slice(2)) + .command( + '*', + 'Generate data and index into Elasticsearch', + (y) => { + return y + .positional('file', fileOption) + .option('bucketSize', bucketSizeOption) + .option('workers', workerOption) + .option('interval', intervalOption) + .option('clean', cleanOption) + .option('target', targetOption) + .option('logLevel', logLevelOption) + .option('from', { + description: 'The start of the time window', + }) + .option('to', { + description: 'The end of the time window', + }) + .option('live', { + description: 'Generate and index data continuously', + boolean: true, + }) + .conflicts('to', 'live'); + }, + async (argv) => { + const { + scenario, + intervalInMs, + bucketSizeInMs, + target, + workers, + clean, + logger, + writeTargets, + client, + } = await getCommonResources(argv); + + if (clean) { + await cleanWriteTargets({ writeTargets, client, logger }); + } + + const to = datemath.parse(String(argv.to ?? 'now'))!.valueOf(); + const from = argv.from + ? datemath.parse(String(argv.from))!.valueOf() + : to - intervalToMs('15m'); + + const live = argv.live; + + logger.info( + `Starting data generation\n: ${JSON.stringify( + { + intervalInMs, + bucketSizeInMs, + workers, + target, + writeTargets, + from: new Date(from).toISOString(), + to: new Date(to).toISOString(), + live, + }, + null, + 2 + )}` + ); + + startHistoricalDataUpload({ + from, + to, + scenario, + intervalInMs, + bucketSizeInMs, + client, + workers, + writeTargets, + logger, + }); + + if (live) { + startLiveDataUpload({ + bucketSizeInMs, + client, + intervalInMs, + logger, + scenario, + start: to, + workers, + writeTargets, + }); + } + } + ) + .parse(); diff --git a/packages/elastic-apm-generator/src/scripts/utils/clean_write_targets.ts b/packages/elastic-apm-generator/src/scripts/utils/clean_write_targets.ts new file mode 100644 index 0000000000000..efa24f164d51e --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/clean_write_targets.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Client } from '@elastic/elasticsearch'; +import { ElasticsearchOutputWriteTargets } from '../../lib/output/to_elasticsearch_output'; +import { Logger } from './logger'; + +export async function cleanWriteTargets({ + writeTargets, + client, + logger, +}: { + writeTargets: ElasticsearchOutputWriteTargets; + client: Client; + logger: Logger; +}) { + const targets = Object.values(writeTargets); + + logger.info(`Cleaning indices: ${targets.join(', ')}`); + + const response = await client.deleteByQuery({ + index: targets, + allow_no_indices: true, + conflicts: 'proceed', + body: { + query: { + match_all: {}, + }, + }, + wait_for_completion: false, + }); + + const task = response.body.task; + + if (task) { + await new Promise((resolve, reject) => { + const pollForTaskCompletion = async () => { + const taskResponse = await client.tasks.get({ + task_id: String(task), + }); + + logger.debug( + `Polled for task:\n${JSON.stringify(taskResponse.body, ['completed', 'error'], 2)}` + ); + + if (taskResponse.body.completed) { + resolve(); + } else if (taskResponse.body.error) { + reject(taskResponse.body.error); + } else { + setTimeout(pollForTaskCompletion, 2500); + } + }; + + pollForTaskCompletion(); + }); + } +} diff --git a/packages/elastic-apm-generator/src/scripts/utils/common_options.ts b/packages/elastic-apm-generator/src/scripts/utils/common_options.ts new file mode 100644 index 0000000000000..eba547114d533 --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/common_options.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const fileOption = { + describe: 'File that contains the trace scenario', + demandOption: true, +}; + +const intervalOption = { + describe: 'The interval at which to index data', + default: '10s', +}; + +const targetOption = { + describe: 'Elasticsearch target, including username/password', + demandOption: true, +}; + +const bucketSizeOption = { + describe: 'Size of bucket for which to generate data', + default: '15m', +}; + +const workerOption = { + describe: 'Amount of simultaneously connected ES clients', + default: 1, +}; + +const cleanOption = { + describe: 'Clean APM indices before indexing new data', + default: false, + boolean: true as const, +}; + +const logLevelOption = { + describe: 'Log level', + default: 'info', +}; + +export { + fileOption, + intervalOption, + targetOption, + bucketSizeOption, + workerOption, + cleanOption, + logLevelOption, +}; diff --git a/packages/elastic-apm-generator/src/scripts/utils/get_common_resources.ts b/packages/elastic-apm-generator/src/scripts/utils/get_common_resources.ts new file mode 100644 index 0000000000000..1288c1390e92c --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/get_common_resources.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Client } from '@elastic/elasticsearch'; +import { getScenario } from './get_scenario'; +import { getWriteTargets } from './get_write_targets'; +import { intervalToMs } from './interval_to_ms'; +import { createLogger, LogLevel } from './logger'; + +export async function getCommonResources({ + file, + interval, + bucketSize, + workers, + target, + clean, + logLevel, +}: { + file: unknown; + interval: unknown; + bucketSize: unknown; + workers: unknown; + target: unknown; + clean: boolean; + logLevel: unknown; +}) { + let parsedLogLevel = LogLevel.info; + switch (logLevel) { + case 'info': + parsedLogLevel = LogLevel.info; + break; + + case 'debug': + parsedLogLevel = LogLevel.debug; + break; + + case 'quiet': + parsedLogLevel = LogLevel.quiet; + break; + } + + const logger = createLogger(parsedLogLevel); + + const intervalInMs = intervalToMs(interval); + if (!intervalInMs) { + throw new Error('Invalid interval'); + } + + const bucketSizeInMs = intervalToMs(bucketSize); + + if (!bucketSizeInMs) { + throw new Error('Invalid bucket size'); + } + + const client = new Client({ + node: String(target), + }); + + const [scenario, writeTargets] = await Promise.all([ + getScenario({ file, logger }), + getWriteTargets({ client }), + ]); + + return { + scenario, + writeTargets, + logger, + client, + intervalInMs, + bucketSizeInMs, + workers: Number(workers), + target: String(target), + clean, + }; +} diff --git a/packages/elastic-apm-generator/src/scripts/utils/get_scenario.ts b/packages/elastic-apm-generator/src/scripts/utils/get_scenario.ts new file mode 100644 index 0000000000000..887969e8459cc --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/get_scenario.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 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 Path from 'path'; +import { Fields } from '../../lib/entity'; +import { Logger } from './logger'; + +export type Scenario = (options: { from: number; to: number }) => Fields[]; + +export function getScenario({ file, logger }: { file: unknown; logger: Logger }) { + const location = Path.join(process.cwd(), String(file)); + + logger.debug(`Loading scenario from ${location}`); + + return import(location).then((m) => { + if (m && m.default) { + return m.default; + } + throw new Error(`Could not find scenario at ${location}`); + }) as Promise; +} diff --git a/packages/elastic-apm-generator/src/scripts/utils/get_write_targets.ts b/packages/elastic-apm-generator/src/scripts/utils/get_write_targets.ts new file mode 100644 index 0000000000000..3640e4efaf796 --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/get_write_targets.ts @@ -0,0 +1,56 @@ +/* + * 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 { Client } from '@elastic/elasticsearch'; +import { ElasticsearchOutputWriteTargets } from '../../lib/output/to_elasticsearch_output'; + +export async function getWriteTargets({ + client, +}: { + client: Client; +}): Promise { + const [indicesResponse, datastreamsResponse] = await Promise.all([ + client.indices.getAlias({ + index: 'apm-*', + }), + client.indices.getDataStream({ + name: '*apm', + }), + ]); + + function getDataStreamName(filter: string) { + return datastreamsResponse.body.data_streams.find((stream) => stream.name.includes(filter)) + ?.name; + } + + function getAlias(filter: string) { + return Object.keys(indicesResponse.body) + .map((key) => { + return { + key, + writeIndexAlias: Object.entries(indicesResponse.body[key].aliases).find( + ([_, alias]) => alias.is_write_index + )?.[0], + }; + }) + .find(({ key }) => key.includes(filter))?.writeIndexAlias!; + } + + const targets = { + transaction: getDataStreamName('traces-apm') || getAlias('-transaction'), + span: getDataStreamName('traces-apm') || getAlias('-span'), + metric: getDataStreamName('metrics-apm') || getAlias('-metric'), + error: getDataStreamName('logs-apm') || getAlias('-error'), + }; + + if (!targets.transaction || !targets.span || !targets.metric || !targets.error) { + throw new Error('Write targets could not be determined'); + } + + return targets; +} diff --git a/packages/elastic-apm-generator/src/scripts/utils/interval_to_ms.ts b/packages/elastic-apm-generator/src/scripts/utils/interval_to_ms.ts new file mode 100644 index 0000000000000..4cba832be3161 --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/interval_to_ms.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export function intervalToMs(interval: unknown) { + const [, valueAsString, unit] = String(interval).split(/(.*)(s|m|h|d|w)/); + + const value = Number(valueAsString); + + switch (unit) { + case 's': + return value * 1000; + case 'm': + return value * 1000 * 60; + + case 'h': + return value * 1000 * 60 * 60; + + case 'd': + return value * 1000 * 60 * 60 * 24; + + case 'w': + return value * 1000 * 60 * 60 * 24 * 7; + } + + throw new Error('Could not parse interval'); +} diff --git a/packages/elastic-apm-generator/src/scripts/utils/logger.ts b/packages/elastic-apm-generator/src/scripts/utils/logger.ts new file mode 100644 index 0000000000000..c9017cb08e663 --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/logger.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export enum LogLevel { + debug = 0, + info = 1, + quiet = 2, +} + +export function createLogger(logLevel: LogLevel) { + return { + debug: (...args: any[]) => { + if (logLevel <= LogLevel.debug) { + // eslint-disable-next-line no-console + console.debug(...args); + } + }, + info: (...args: any[]) => { + if (logLevel <= LogLevel.info) { + // eslint-disable-next-line no-console + console.log(...args); + } + }, + }; +} + +export type Logger = ReturnType; diff --git a/packages/elastic-apm-generator/src/scripts/utils/start_historical_data_upload.ts b/packages/elastic-apm-generator/src/scripts/utils/start_historical_data_upload.ts new file mode 100644 index 0000000000000..db14090dd1d8f --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/start_historical_data_upload.ts @@ -0,0 +1,64 @@ +/* + * 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 { Client } from '@elastic/elasticsearch'; +import { ElasticsearchOutputWriteTargets } from '../../lib/output/to_elasticsearch_output'; +import { Scenario } from './get_scenario'; +import { Logger } from './logger'; +import { uploadEvents } from './upload_events'; + +export async function startHistoricalDataUpload({ + from, + to, + scenario, + intervalInMs, + bucketSizeInMs, + client, + workers, + writeTargets, + logger, +}: { + from: number; + to: number; + scenario: Scenario; + intervalInMs: number; + bucketSizeInMs: number; + client: Client; + workers: number; + writeTargets: ElasticsearchOutputWriteTargets; + logger: Logger; +}) { + let requestedUntil: number = from; + function uploadNextBatch() { + const bucketFrom = requestedUntil; + const bucketTo = Math.min(to, bucketFrom + bucketSizeInMs); + + const events = scenario({ from: bucketFrom, to: bucketTo }); + + logger.info( + `Uploading: ${new Date(bucketFrom).toISOString()} to ${new Date(bucketTo).toISOString()}` + ); + + uploadEvents({ + events, + client, + workers, + writeTargets, + logger, + }).then(() => { + if (bucketTo >= to) { + return; + } + uploadNextBatch(); + }); + + requestedUntil = bucketTo; + } + + return uploadNextBatch(); +} diff --git a/packages/elastic-apm-generator/src/scripts/utils/start_live_data_upload.ts b/packages/elastic-apm-generator/src/scripts/utils/start_live_data_upload.ts new file mode 100644 index 0000000000000..bf330732f343e --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/start_live_data_upload.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Client } from '@elastic/elasticsearch'; +import { partition } from 'lodash'; +import { Fields } from '../../lib/entity'; +import { ElasticsearchOutputWriteTargets } from '../../lib/output/to_elasticsearch_output'; +import { Scenario } from './get_scenario'; +import { Logger } from './logger'; +import { uploadEvents } from './upload_events'; + +export function startLiveDataUpload({ + start, + bucketSizeInMs, + intervalInMs, + workers, + writeTargets, + scenario, + client, + logger, +}: { + start: number; + bucketSizeInMs: number; + intervalInMs: number; + workers: number; + writeTargets: ElasticsearchOutputWriteTargets; + scenario: Scenario; + client: Client; + logger: Logger; +}) { + let queuedEvents: Fields[] = []; + let requestedUntil: number = start; + + function uploadNextBatch() { + const end = new Date().getTime(); + if (end > requestedUntil) { + const bucketFrom = requestedUntil; + const bucketTo = requestedUntil + bucketSizeInMs; + const nextEvents = scenario({ from: bucketFrom, to: bucketTo }); + logger.debug( + `Requesting ${new Date(bucketFrom).toISOString()} to ${new Date( + bucketTo + ).toISOString()}, events: ${nextEvents.length}` + ); + queuedEvents.push(...nextEvents); + requestedUntil = bucketTo; + } + + const [eventsToUpload, eventsToRemainInQueue] = partition( + queuedEvents, + (event) => event['@timestamp']! <= end + ); + + logger.info(`Uploading until ${new Date(end).toISOString()}, events: ${eventsToUpload.length}`); + + queuedEvents = eventsToRemainInQueue; + + uploadEvents({ + events: eventsToUpload, + client, + workers, + writeTargets, + logger, + }); + } + + setInterval(uploadNextBatch, intervalInMs); + + uploadNextBatch(); +} diff --git a/packages/elastic-apm-generator/src/scripts/utils/upload_events.ts b/packages/elastic-apm-generator/src/scripts/utils/upload_events.ts new file mode 100644 index 0000000000000..89cf4d4602177 --- /dev/null +++ b/packages/elastic-apm-generator/src/scripts/utils/upload_events.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Client } from '@elastic/elasticsearch'; +import { chunk } from 'lodash'; +import pLimit from 'p-limit'; +import { inspect } from 'util'; +import { Fields } from '../../lib/entity'; +import { + ElasticsearchOutputWriteTargets, + toElasticsearchOutput, +} from '../../lib/output/to_elasticsearch_output'; +import { Logger } from './logger'; + +export function uploadEvents({ + events, + client, + workers, + writeTargets, + logger, +}: { + events: Fields[]; + client: Client; + workers: number; + writeTargets: ElasticsearchOutputWriteTargets; + logger: Logger; +}) { + const esDocuments = toElasticsearchOutput({ events, writeTargets }); + const fn = pLimit(workers); + + const batches = chunk(esDocuments, 5000); + + logger.debug(`Uploading ${esDocuments.length} in ${batches.length} batches`); + + const time = new Date().getTime(); + + return Promise.all( + batches.map((batch) => + fn(() => { + return client.bulk({ + require_alias: true, + body: batch.flatMap((doc) => { + return [{ index: { _index: doc._index } }, doc._source]; + }), + }); + }) + ) + ) + .then((results) => { + const errors = results + .flatMap((result) => result.body.items) + .filter((item) => !!item.index?.error) + .map((item) => item.index?.error); + + if (errors.length) { + // eslint-disable-next-line no-console + console.error(inspect(errors.slice(0, 10), { depth: null })); + throw new Error('Failed to upload some items'); + } + + logger.debug(`Uploaded ${events.length} in ${new Date().getTime() - time}ms`); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); + }); +} diff --git a/packages/elastic-apm-generator/src/test/scenarios/01_simple_trace.test.ts b/packages/elastic-apm-generator/src/test/scenarios/01_simple_trace.test.ts index 733093ce0a71c..866a9745befc3 100644 --- a/packages/elastic-apm-generator/src/test/scenarios/01_simple_trace.test.ts +++ b/packages/elastic-apm-generator/src/test/scenarios/01_simple_trace.test.ts @@ -18,7 +18,7 @@ describe('simple trace', () => { const range = timerange( new Date('2021-01-01T00:00:00.000Z').getTime(), - new Date('2021-01-01T00:15:00.000Z').getTime() - 1 + new Date('2021-01-01T00:15:00.000Z').getTime() ); events = range diff --git a/packages/elastic-apm-generator/src/test/scenarios/02_transaction_metrics.test.ts b/packages/elastic-apm-generator/src/test/scenarios/02_transaction_metrics.test.ts index 0b9f192d3d27d..58b28f71b9afc 100644 --- a/packages/elastic-apm-generator/src/test/scenarios/02_transaction_metrics.test.ts +++ b/packages/elastic-apm-generator/src/test/scenarios/02_transaction_metrics.test.ts @@ -19,7 +19,7 @@ describe('transaction metrics', () => { const range = timerange( new Date('2021-01-01T00:00:00.000Z').getTime(), - new Date('2021-01-01T00:15:00.000Z').getTime() - 1 + new Date('2021-01-01T00:15:00.000Z').getTime() ); events = getTransactionMetrics( diff --git a/packages/elastic-apm-generator/src/test/scenarios/03_span_destination_metrics.test.ts b/packages/elastic-apm-generator/src/test/scenarios/03_span_destination_metrics.test.ts index 158ccc5b5e714..0bf59f044bf03 100644 --- a/packages/elastic-apm-generator/src/test/scenarios/03_span_destination_metrics.test.ts +++ b/packages/elastic-apm-generator/src/test/scenarios/03_span_destination_metrics.test.ts @@ -19,7 +19,7 @@ describe('span destination metrics', () => { const range = timerange( new Date('2021-01-01T00:00:00.000Z').getTime(), - new Date('2021-01-01T00:15:00.000Z').getTime() - 1 + new Date('2021-01-01T00:15:00.000Z').getTime() ); events = getSpanDestinationMetrics( diff --git a/packages/elastic-apm-generator/src/test/scenarios/04_breakdown_metrics.test.ts b/packages/elastic-apm-generator/src/test/scenarios/04_breakdown_metrics.test.ts index aeb944f35faf6..469f56b99c5f2 100644 --- a/packages/elastic-apm-generator/src/test/scenarios/04_breakdown_metrics.test.ts +++ b/packages/elastic-apm-generator/src/test/scenarios/04_breakdown_metrics.test.ts @@ -26,7 +26,7 @@ describe('breakdown metrics', () => { const start = new Date('2021-01-01T00:00:00.000Z').getTime(); - const range = timerange(start, start + INTERVALS * 30 * 1000 - 1); + const range = timerange(start, start + INTERVALS * 30 * 1000); events = getBreakdownMetrics([ ...range diff --git a/packages/elastic-apm-generator/src/test/to_elasticsearch_output.test.ts b/packages/elastic-apm-generator/src/test/to_elasticsearch_output.test.ts index c1a5d47654fc9..d15ea89083112 100644 --- a/packages/elastic-apm-generator/src/test/to_elasticsearch_output.test.ts +++ b/packages/elastic-apm-generator/src/test/to_elasticsearch_output.test.ts @@ -9,6 +9,13 @@ import { Fields } from '../lib/entity'; import { toElasticsearchOutput } from '../lib/output/to_elasticsearch_output'; +const writeTargets = { + transaction: 'apm-8.0.0-transaction', + span: 'apm-8.0.0-span', + metric: 'apm-8.0.0-metric', + error: 'apm-8.0.0-error', +}; + describe('output to elasticsearch', () => { let event: Fields; @@ -21,13 +28,13 @@ describe('output to elasticsearch', () => { }); it('properly formats @timestamp', () => { - const doc = toElasticsearchOutput([event])[0] as any; + const doc = toElasticsearchOutput({ events: [event], writeTargets })[0] as any; expect(doc._source['@timestamp']).toEqual('2020-12-31T23:00:00.000Z'); }); it('formats a nested object', () => { - const doc = toElasticsearchOutput([event])[0] as any; + const doc = toElasticsearchOutput({ events: [event], writeTargets })[0] as any; expect(doc._source.processor).toEqual({ event: 'transaction', diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 6987b779d5d45..571b564f90329 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -644,6 +644,11 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` > } + buttonElement="button" className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-recentlyViewed" + element="div" id="generated-id" initialIsOpen={true} isLoading={false} @@ -685,28 +692,13 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` + + +
} + buttonElement="button" className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-kibana" + element="div" id="generated-id" initialIsOpen={true} isLoading={false} @@ -941,28 +977,13 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` + + +
} + buttonElement="button" className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-observability" + element="div" id="generated-id" initialIsOpen={true} isLoading={false} @@ -1233,28 +1298,13 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` + + +
} + buttonElement="button" className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-securitySolution" + element="div" id="generated-id" initialIsOpen={true} isLoading={false} @@ -1486,28 +1580,13 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` + + +
} + buttonElement="button" className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" data-test-subj="collapsibleNavGroup-management" + element="div" id="generated-id" initialIsOpen={true} isLoading={false} @@ -1700,28 +1823,13 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` + + +
{ diff --git a/src/core/server/saved_objects/saved_objects_config.test.ts b/src/core/server/saved_objects/saved_objects_config.test.ts deleted file mode 100644 index 06b9e9661b746..0000000000000 --- a/src/core/server/saved_objects/saved_objects_config.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { savedObjectsMigrationConfig } from './saved_objects_config'; -import { getDeprecationsFor } from '../config/test_utils'; - -const applyMigrationsDeprecations = (settings: Record = {}) => - getDeprecationsFor({ - provider: savedObjectsMigrationConfig.deprecations!, - settings, - path: 'migrations', - }); - -describe('migrations config', function () { - describe('deprecations', () => { - it('logs a warning if migrations.enableV2 is set: true', () => { - const { messages } = applyMigrationsDeprecations({ enableV2: true }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "You no longer need to configure \\"migrations.enableV2\\".", - ] - `); - }); - - it('logs a warning if migrations.enableV2 is set: false', () => { - const { messages } = applyMigrationsDeprecations({ enableV2: false }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "You no longer need to configure \\"migrations.enableV2\\".", - ] - `); - }); - }); - - it('does not log a warning if migrations.enableV2 is not set', () => { - const { messages } = applyMigrationsDeprecations({ batchSize: 1_000 }); - expect(messages).toMatchInlineSnapshot(`Array []`); - }); -}); diff --git a/src/core/server/saved_objects/saved_objects_config.ts b/src/core/server/saved_objects/saved_objects_config.ts index 02fbd974da4ae..e5dc64186f66d 100644 --- a/src/core/server/saved_objects/saved_objects_config.ts +++ b/src/core/server/saved_objects/saved_objects_config.ts @@ -7,7 +7,6 @@ */ import { schema, TypeOf } from '@kbn/config-schema'; -import { ConfigDeprecationProvider } from '../config'; import type { ServiceConfigDescriptor } from '../internal_types'; const migrationSchema = schema.object({ @@ -21,13 +20,10 @@ const migrationSchema = schema.object({ export type SavedObjectsMigrationConfigType = TypeOf; -const migrationDeprecations: ConfigDeprecationProvider = ({ unused }) => [unused('enableV2')]; - export const savedObjectsMigrationConfig: ServiceConfigDescriptor = { path: 'migrations', schema: migrationSchema, - deprecations: migrationDeprecations, }; const soSchema = schema.object({ diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index efa54e74fdf2f..305eeb9a6a358 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -75,6 +75,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@7.16.0': ['Elastic License 2.0'], - '@elastic/eui@39.1.1': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@40.0.0': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx index c3b4075690261..3237eb106e4ec 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.test.tsx @@ -206,7 +206,9 @@ describe('Dashboard container lifecycle', () => { }); }); -describe('Dashboard initial state', () => { +// FLAKY: https://github.com/elastic/kibana/issues/116050 +// FLAKY: https://github.com/elastic/kibana/issues/105018 +describe.skip('Dashboard initial state', () => { it('Extracts state from Dashboard Saved Object', async () => { const { renderHookResult, embeddableFactoryResult } = renderDashboardAppStateHook({}); const getResult = () => renderHookResult.result.current; @@ -276,7 +278,8 @@ describe('Dashboard initial state', () => { }); }); -describe('Dashboard state sync', () => { +// FLAKY: https://github.com/elastic/kibana/issues/116043 +describe.skip('Dashboard state sync', () => { let defaultDashboardAppStateHookResult: RenderDashboardStateHookReturn; const getResult = () => defaultDashboardAppStateHookResult.renderHookResult.result.current; diff --git a/src/plugins/data_views/server/saved_objects/data_views.ts b/src/plugins/data_views/server/saved_objects/data_views.ts index 5bb85a9bb6e98..ca7592732c3ee 100644 --- a/src/plugins/data_views/server/saved_objects/data_views.ts +++ b/src/plugins/data_views/server/saved_objects/data_views.ts @@ -13,7 +13,8 @@ import { DATA_VIEW_SAVED_OBJECT_TYPE } from '../../common'; export const dataViewSavedObjectType: SavedObjectsType = { name: DATA_VIEW_SAVED_OBJECT_TYPE, hidden: false, - namespaceType: 'single', + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', management: { displayName: 'Data view', icon: 'indexPatternApp', diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/_table_header.scss b/src/plugins/discover/public/application/apps/main/components/doc_table/components/_table_header.scss index 3450084e19269..3ea6fb5502764 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/components/_table_header.scss +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/_table_header.scss @@ -1,7 +1,8 @@ .kbnDocTableHeader { white-space: nowrap; } -.kbnDocTableHeader button { +.kbnDocTableHeader button, +.kbnDocTableHeader svg { margin-left: $euiSizeXS * .5; } .kbnDocTableHeader__move, diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap index 18b0ae8699e3e..3f72349f3e2a0 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap @@ -15,7 +15,16 @@ exports[`TableHeader with time column renders correctly 1`] = ` class="kbnDocTableHeader__actions" data-test-subj="docTableHeader-time" > - Time + time + + + diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/helpers.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/helpers.tsx index d313e95c1ebb1..f04454d33e9f2 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/helpers.tsx +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/helpers.tsx @@ -28,7 +28,7 @@ export interface ColumnProps { export function getTimeColumn(timeFieldName: string): ColumnProps { return { name: timeFieldName, - displayName: 'Time', + displayName: timeFieldName, isSortable: true, isRemoveable: false, colLeftIdx: -1, diff --git a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header.tsx b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header.tsx index f891e809ee702..1877c014ddcbd 100644 --- a/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header.tsx +++ b/src/plugins/discover/public/application/apps/main/components/doc_table/components/table_header/table_header.tsx @@ -45,6 +45,8 @@ export function TableHeader({ void; onMoveColumn?: (name: string, idx: number) => void; @@ -54,6 +56,8 @@ export function TableHeaderColumn({ displayName, isRemoveable, isSortable, + isTimeColumn, + customLabel, name, onChangeSortOrder, onMoveColumn, @@ -65,6 +69,14 @@ export function TableHeaderColumn({ const curColSort = sortOrder.find((pair) => pair[0] === name); const curColSortDir = (curColSort && curColSort[1]) || ''; + const timeAriaLabel = i18n.translate( + 'discover.docTable.tableHeader.timeFieldIconTooltipAriaLabel', + { defaultMessage: 'Primary time field.' } + ); + const timeTooltip = i18n.translate('discover.docTable.tableHeader.timeFieldIconTooltip', { + defaultMessage: 'This field represents the time that events occurred.', + }); + // If this is the _score column, and _score is not one of the columns inside the sort, show a // warning that the _score will not be retrieved from Elasticsearch const showScoreSortWarning = name === '_score' && !curColSort; @@ -183,7 +195,15 @@ export function TableHeaderColumn({ {showScoreSortWarning && } - {displayName} + {customLabel ?? displayName} + {isTimeColumn && ( + + )} {buttons .filter((button) => button.active) .map((button, idx) => ( diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx index 46e30dd23525b..e5ea657032403 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx @@ -117,7 +117,15 @@ describe('Discover grid columns ', function () { [Function], [Function], ], - "display": "Time (timestamp)", + "display": + timestamp + + + , "id": "timestamp", "initialWidth": 190, "isSortable": true, diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx index 2f4c0b5167df8..5eb55a8e99cde 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { Fragment } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiDataGridColumn, EuiScreenReaderOnly } from '@elastic/eui'; +import { EuiDataGridColumn, EuiIconTip, EuiScreenReaderOnly } from '@elastic/eui'; import { ExpandButton } from './discover_grid_expand_button'; import { DiscoverGridSettings } from './types'; import type { IndexPattern } from '../../../../../data/common'; @@ -57,9 +57,6 @@ export function buildEuiGridColumn( defaultColumns: boolean, isSortEnabled: boolean ) { - const timeString = i18n.translate('discover.timeLabel', { - defaultMessage: 'Time', - }); const indexPatternField = indexPattern.getFieldByName(columnName); const column: EuiDataGridColumn = { id: columnName, @@ -88,7 +85,23 @@ export function buildEuiGridColumn( }; if (column.id === indexPattern.timeFieldName) { - column.display = `${timeString} (${indexPattern.timeFieldName})`; + const primaryTimeAriaLabel = i18n.translate( + 'discover.docTable.tableHeader.timeFieldIconTooltipAriaLabel', + { defaultMessage: 'Primary time field.' } + ); + const primaryTimeTooltip = i18n.translate( + 'discover.docTable.tableHeader.timeFieldIconTooltip', + { + defaultMessage: 'This field represents the time that events occurred.', + } + ); + + column.display = ( + + {indexPatternField?.customLabel ?? indexPattern.timeFieldName}{' '} + + + ); column.initialWidth = defaultTimeColumnWidth; } if (columnWidth > 0) { diff --git a/src/plugins/presentation_util/kibana.json b/src/plugins/presentation_util/kibana.json index d7fe9b558e606..71ac224d1976a 100644 --- a/src/plugins/presentation_util/kibana.json +++ b/src/plugins/presentation_util/kibana.json @@ -10,6 +10,6 @@ "server": true, "ui": true, "extraPublicDirs": ["common/lib"], - "requiredPlugins": ["savedObjects"], + "requiredPlugins": ["savedObjects", "kibanaReact"], "optionalPlugins": [] } diff --git a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx new file mode 100644 index 0000000000000..1a29d0536a290 --- /dev/null +++ b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.stories.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { DataView, DataViewField, IIndexPatternFieldList } from '../../../../data_views/common'; + +import { StorybookParams } from '../../services/storybook'; +import { DataViewPicker } from './data_view_picker'; + +// TODO: we probably should remove this once the PR is merged that has better data views for stories +const flightFieldNames: string[] = [ + 'AvgTicketPrice', + 'Cancelled', + 'Carrier', + 'dayOfWeek', + 'Dest', + 'DestAirportID', + 'DestCityName', + 'DestCountry', + 'DestLocation', + 'DestRegion', + 'DestWeather', + 'DistanceKilometers', + 'DistanceMiles', + 'FlightDelay', + 'FlightDelayMin', + 'FlightDelayType', + 'FlightNum', + 'FlightTimeHour', + 'FlightTimeMin', + 'Origin', + 'OriginAirportID', + 'OriginCityName', + 'OriginCountry', + 'OriginLocation', + 'OriginRegion', + 'OriginWeather', + 'timestamp', +]; +const flightFieldByName: { [key: string]: DataViewField } = {}; +flightFieldNames.forEach( + (flightFieldName) => + (flightFieldByName[flightFieldName] = { + name: flightFieldName, + type: 'string', + } as unknown as DataViewField) +); + +// Change some types manually for now +flightFieldByName.Cancelled = { name: 'Cancelled', type: 'boolean' } as DataViewField; +flightFieldByName.timestamp = { name: 'timestamp', type: 'date' } as DataViewField; + +const flightFields: DataViewField[] = Object.values(flightFieldByName); +const storybookFlightsDataView: DataView = { + id: 'demoDataFlights', + title: 'demo data flights', + fields: flightFields as unknown as IIndexPatternFieldList, + getFieldByName: (name: string) => flightFieldByName[name], +} as unknown as DataView; + +export default { + component: DataViewPicker, + title: 'Data View Picker', + argTypes: {}, +}; + +export function Example({}: {} & StorybookParams) { + const dataViews = [storybookFlightsDataView]; + + const [dataView, setDataView] = useState(undefined); + + const onChange = (newId: string) => { + const newIndexPattern = dataViews.find((ip) => ip.id === newId); + + setDataView(newIndexPattern); + }; + + const triggerLabel = dataView?.title || 'Choose Data View'; + + return ( + + ); +} diff --git a/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx new file mode 100644 index 0000000000000..38ec4f16f9432 --- /dev/null +++ b/src/plugins/presentation_util/public/components/data_view_picker/data_view_picker.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { EuiPopover, EuiPopoverTitle, EuiSelectable, EuiSelectableProps } from '@elastic/eui'; +import { DataView } from '../../../../data_views/common'; + +import { ToolbarButton, ToolbarButtonProps } from '../../../../kibana_react/public'; + +export type DataViewTriggerProps = ToolbarButtonProps & { + label: string; + title?: string; +}; + +export function DataViewPicker({ + dataViews, + selectedDataViewId, + onChangeIndexPattern, + trigger, + selectableProps, +}: { + dataViews: DataView[]; + selectedDataViewId?: string; + trigger: DataViewTriggerProps; + onChangeIndexPattern: (newId: string) => void; + selectableProps?: EuiSelectableProps; +}) { + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + + const isMissingCurrent = !dataViews.some(({ id }) => id === selectedDataViewId); + + // be careful to only add color with a value, otherwise it will fallbacks to "primary" + const colorProp = isMissingCurrent + ? { + color: 'danger' as const, + } + : {}; + + const createTrigger = function () { + const { label, title, ...rest } = trigger; + return ( + setPopoverIsOpen(!isPopoverOpen)} + fullWidth + {...colorProp} + {...rest} + > + {label} + + ); + }; + + return ( + <> + setPopoverIsOpen(false)} + display="block" + panelPaddingSize="s" + ownFocus + > +
+ + {i18n.translate('presentationUtil.dataViewPicker.changeDataViewTitle', { + defaultMessage: 'Data view', + })} + + + {...selectableProps} + searchable + singleSelection="always" + options={dataViews.map(({ title, id }) => ({ + key: id, + label: title, + value: id, + checked: id === selectedDataViewId ? 'on' : undefined, + }))} + onChange={(choices) => { + const choice = choices.find(({ checked }) => checked) as unknown as { + value: string; + }; + onChangeIndexPattern(choice.value); + setPopoverIsOpen(false); + }} + searchProps={{ + compressed: true, + ...(selectableProps ? selectableProps.searchProps : undefined), + }} + > + {(list, search) => ( + <> + {search} + {list} + + )} + +
+
+ + ); +} diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.scss b/src/plugins/presentation_util/public/components/field_picker/field_picker.scss new file mode 100644 index 0000000000000..c07cf99ed03d6 --- /dev/null +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.scss @@ -0,0 +1,15 @@ +.presFieldPicker__fieldButton { + box-shadow: 0 .8px .8px rgba(0,0,0,.04),0 2.3px 2px rgba(0,0,0,.03); + background: #FFF; + border: 1px dashed transparent; +} + +.presFieldPicker__fieldPanel { + height: 300px; + overflow-y: scroll; +} + +.presFieldPicker__container--disabled { + opacity: .7; + pointer-events: none; +} \ No newline at end of file diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx b/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx new file mode 100644 index 0000000000000..c5654254ea70a --- /dev/null +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.stories.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { FieldPicker } from './field_picker'; + +import { DataView, DataViewField, IIndexPatternFieldList } from '../../../../data_views/common'; + +// TODO: we probably should remove this once the PR is merged that has better data views for stories +const flightFieldNames: string[] = [ + 'AvgTicketPrice', + 'Cancelled', + 'Carrier', + 'dayOfWeek', + 'Dest', + 'DestAirportID', + 'DestCityName', + 'DestCountry', + 'DestLocation', + 'DestRegion', + 'DestWeather', + 'DistanceKilometers', + 'DistanceMiles', + 'FlightDelay', + 'FlightDelayMin', + 'FlightDelayType', + 'FlightNum', + 'FlightTimeHour', + 'FlightTimeMin', + 'Origin', + 'OriginAirportID', + 'OriginCityName', + 'OriginCountry', + 'OriginLocation', + 'OriginRegion', + 'OriginWeather', + 'timestamp', +]; +const flightFieldByName: { [key: string]: DataViewField } = {}; +flightFieldNames.forEach( + (flightFieldName) => + (flightFieldByName[flightFieldName] = { + name: flightFieldName, + type: 'string', + } as unknown as DataViewField) +); + +// Change some types manually for now +flightFieldByName.Cancelled = { name: 'Cancelled', type: 'boolean' } as DataViewField; +flightFieldByName.timestamp = { name: 'timestamp', type: 'date' } as DataViewField; + +const flightFields: DataViewField[] = Object.values(flightFieldByName); +const storybookFlightsDataView: DataView = { + id: 'demoDataFlights', + title: 'demo data flights', + fields: flightFields as unknown as IIndexPatternFieldList, + getFieldByName: (name: string) => flightFieldByName[name], +} as unknown as DataView; + +export default { + component: FieldPicker, + title: 'Field Picker', +}; + +export const FieldPickerWithDataView = () => { + return ; +}; + +export const FieldPickerWithFilter = () => { + return ( + { + // Only show fields with "Dest" in the title + return f.name.includes('Dest'); + }} + /> + ); +}; + +export const FieldPickerWithoutIndexPattern = () => { + return ; +}; diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx new file mode 100644 index 0000000000000..bbdf389ccee14 --- /dev/null +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { sortBy, uniq } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DataView, DataViewField } from '../../../../data_views/common'; +import { FieldIcon, FieldButton } from '../../../../kibana_react/public'; + +import { FieldSearch } from './field_search'; + +import './field_picker.scss'; + +export interface Props { + dataView: DataView | null; + filterPredicate?: (f: DataViewField) => boolean; +} + +export const FieldPicker = ({ dataView, filterPredicate }: Props) => { + const [nameFilter, setNameFilter] = useState(''); + const [typesFilter, setTypesFilter] = useState([]); + const [selectedField, setSelectedField] = useState(null); + + // Retrieve, filter, and sort fields from data view + const fields = dataView + ? sortBy( + dataView.fields + .filter( + (f) => + f.name.includes(nameFilter) && + (typesFilter.length === 0 || typesFilter.includes(f.type as string)) + ) + .filter((f) => (filterPredicate ? filterPredicate(f) : true)), + ['name'] + ) + : []; + + const uniqueTypes = dataView ? uniq(dataView.fields.map((f) => f.type as string)) : []; + + return ( + + + setNameFilter(val)} + searchValue={nameFilter} + onFieldTypesChange={(types) => setTypesFilter(types)} + fieldTypesValue={typesFilter} + availableFieldTypes={uniqueTypes} + /> + + + + {fields.length > 0 && ( + + {fields.map((f, i) => { + return ( + + setSelectedField(f)} + isActive={f.name === selectedField?.name} + fieldName={f.name} + fieldIcon={} + /> + + ); + })} + + )} + {!dataView && ( + + + + + + + + )} + {dataView && fields.length === 0 && ( + + + + + + + + )} + + + {selectedField && ( + + +

+ +

+
+
+ + } + /> +
+
+ )} +
+ ); +}; diff --git a/src/plugins/presentation_util/public/components/field_picker/field_search.tsx b/src/plugins/presentation_util/public/components/field_picker/field_search.tsx new file mode 100644 index 0000000000000..d3c6c728b3d08 --- /dev/null +++ b/src/plugins/presentation_util/public/components/field_picker/field_search.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFieldSearch, + EuiFilterGroup, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiOutsideClickDetector, + EuiFilterButton, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { FieldIcon } from '../../../../kibana_react/public'; + +export interface Props { + onSearchChange: (value: string) => void; + searchValue?: string; + + onFieldTypesChange: (value: string[]) => void; + fieldTypesValue: string[]; + + availableFieldTypes: string[]; +} + +export function FieldSearch({ + onSearchChange, + searchValue, + onFieldTypesChange, + fieldTypesValue, + availableFieldTypes, +}: Props) { + const searchPlaceholder = i18n.translate('presentationUtil.fieldSearch.searchPlaceHolder', { + defaultMessage: 'Search field names', + }); + + const [isPopoverOpen, setPopoverOpen] = useState(false); + + const handleFilterButtonClicked = () => { + setPopoverOpen(!isPopoverOpen); + }; + + const buttonContent = ( + 0} + numFilters={0} + hasActiveFilters={fieldTypesValue.length > 0} + numActiveFilters={fieldTypesValue.length} + onClick={handleFilterButtonClicked} + > + + + ); + + return ( + + + + onSearchChange(event.currentTarget.value)} + placeholder={searchPlaceholder} + value={searchValue} + /> + + + + {}} isDisabled={!isPopoverOpen}> + + { + setPopoverOpen(false); + }} + button={buttonContent} + > + ( + { + if (fieldTypesValue.includes(type)) { + onFieldTypesChange(fieldTypesValue.filter((f) => f !== type)); + } else { + onFieldTypesChange([...fieldTypesValue, type]); + } + }} + > + + + {type} + + + ))} + /> + + + + + ); +} diff --git a/src/plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts index 02ac428b07667..97180f351986e 100644 --- a/src/plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -230,6 +230,7 @@ export class FetcherTask { method: 'post', body: stats, headers: { + 'Content-Type': 'application/json', 'X-Elastic-Stack-Version': this.currentKibanaVersion, 'X-Elastic-Cluster-ID': clusterUuid, 'X-Elastic-Content-Encoding': PAYLOAD_CONTENT_ENCODING, diff --git a/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap b/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap index bc6d28bd5c1c4..b25444d16c46a 100644 --- a/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap +++ b/src/plugins/vis_default_editor/public/components/__snapshots__/agg.test.tsx.snap @@ -12,8 +12,10 @@ exports[`DefaultEditorAgg component should init with the default set of props 1` } buttonContentClassName="visEditorSidebar__aggGroupAccordionButtonContent eui-textTruncate" + buttonElement="button" className="visEditorSidebar__section visEditorSidebar__collapsible visEditorSidebar__collapsible--marginBottom" data-test-subj="visEditorAggAccordion1" + element="div" extraAction={
)} - + + +
+ } + > + + ); diff --git a/src/plugins/vis_types/timeseries/public/timeseries_vis_renderer.tsx b/src/plugins/vis_types/timeseries/public/timeseries_vis_renderer.tsx index 34cc1dc347ef8..ad069a4d7e2cc 100644 --- a/src/plugins/vis_types/timeseries/public/timeseries_vis_renderer.tsx +++ b/src/plugins/vis_types/timeseries/public/timeseries_vis_renderer.tsx @@ -13,6 +13,7 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { IUiSettingsClient } from 'kibana/public'; +import { EuiLoadingChart } from '@elastic/eui'; import { fetchIndexPattern } from '../common/index_patterns_utils'; import { VisualizationContainer, PersistedState } from '../../../visualizations/public'; @@ -43,10 +44,6 @@ export const getTimeseriesVisRenderer: (deps: { name: 'timeseries_vis', reuseDomNode: true, render: async (domNode, config, handlers) => { - // Build optimization. Move app styles from main bundle - // @ts-expect-error TS error, cannot find type declaration for scss - await import('./application/index.scss'); - handlers.onDestroy(() => { unmountComponentAtNode(domNode); }); @@ -55,33 +52,49 @@ export const getTimeseriesVisRenderer: (deps: { const { indexPatterns } = getDataStart(); const showNoResult = !checkIfDataExists(visData, model); - const [palettesService, { indexPattern }] = await Promise.all([ + + let servicesLoaded; + + Promise.all([ palettes.getPalettes(), fetchIndexPattern(model.index_pattern, indexPatterns), - ]); + ]).then(([palettesService, { indexPattern }]) => { + servicesLoaded = true; - render( - - - + - - , - domNode - ); + showNoResult={showNoResult} + error={get(visData, [model.id, 'error'])} + > + + + , + domNode + ); + }); + + if (!servicesLoaded) { + render( +
+ +
, + domNode + ); + } }, }); diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/get_interval_and_timefield.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/get_interval_and_timefield.ts index b7a22abd825e0..7c17f003dfbab 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/get_interval_and_timefield.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/get_interval_and_timefield.ts @@ -24,10 +24,15 @@ export function getIntervalAndTimefield( { min, max, maxBuckets }: IntervalParams, series?: Series ) { - const timeField = + let timeField = (series?.override_index_pattern ? series.series_time_field : panel.time_field) || index.indexPattern?.timeFieldName; + // should use @timestamp as default timeField for es indeces if user doesn't provide timeField + if (!panel.use_kibana_indexes && !timeField) { + timeField = '@timestamp'; + } + if (panel.use_kibana_indexes) { validateField(timeField!, index); } diff --git a/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/__snapshots__/value_axes_panel.test.tsx.snap b/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/__snapshots__/value_axes_panel.test.tsx.snap index f50836e6ca8af..1dd916f827fe6 100644 --- a/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/__snapshots__/value_axes_panel.test.tsx.snap +++ b/src/plugins/vis_types/xy/public/editor/components/options/metrics_axes/__snapshots__/value_axes_panel.test.tsx.snap @@ -66,8 +66,10 @@ exports[`ValueAxesPanel component should init with the default set of props 1`] } buttonContentClassName="visEditorSidebar__aggGroupAccordionButtonContent eui-textTruncate" + buttonElement="button" className="visEditorSidebar__section visEditorSidebar__collapsible" data-test-subj="toggleYAxisOptions-ValueAxis-1" + element="div" extraAction={ } buttonContentClassName="visEditorSidebar__aggGroupAccordionButtonContent eui-textTruncate" + buttonElement="button" className="visEditorSidebar__section visEditorSidebar__collapsible" data-test-subj="toggleYAxisOptions-ValueAxis-2" + element="div" extraAction={ + +
, + this.domNode + ); + const expressions = getExpressions(); this.handler = await expressions.loader(this.domNode, undefined, { onRenderError: (element: HTMLElement, error: ExpressionRenderError) => { diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_async.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable_async.ts index c7480844adbea..2fa22cfe8d80b 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_async.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_async.ts @@ -12,10 +12,12 @@ export const createVisualizeEmbeddableAsync = async ( ...args: ConstructorParameters ) => { // Build optimization. Move app styles from main bundle - // @ts-expect-error TS error, cannot find type declaration for scss - await import('./embeddables.scss'); - const { VisualizeEmbeddable } = await import('./visualize_embeddable'); + const [{ VisualizeEmbeddable }] = await Promise.all([ + import('./visualize_embeddable'), + // @ts-expect-error TS error, cannot find type declaration for scss + import('./embeddables.scss'), + ]); return new VisualizeEmbeddable(...args); }; diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 0b744b7991b38..ea7f297dfeb08 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -249,7 +249,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/management/kibana/dataViews/dataView/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, - namespaceType: 'single', + namespaceType: 'multiple-isolated', }); })); }); diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index 518ec29947016..838bc05346dda 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -91,7 +91,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/management/kibana/dataViews/dataView/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, - namespaceType: 'single', + namespaceType: 'multiple-isolated', hiddenType: false, }, }, @@ -132,7 +132,7 @@ export default function ({ getService }: FtrProviderContext) { path: '/app/management/kibana/dataViews/dataView/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.kibana.indexPatterns', }, - namespaceType: 'single', + namespaceType: 'multiple-isolated', hiddenType: false, }, relationship: 'child', diff --git a/test/functional/apps/context/_discover_navigation.ts b/test/functional/apps/context/_discover_navigation.ts index 1b8300f3345b1..60745bd64b8be 100644 --- a/test/functional/apps/context/_discover_navigation.ts +++ b/test/functional/apps/context/_discover_navigation.ts @@ -74,7 +74,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should open the context view with the same columns', async () => { const columnNames = await docTable.getHeaderFields(); - expect(columnNames).to.eql(['Time', ...TEST_COLUMN_NAMES]); + expect(columnNames).to.eql(['@timestamp', ...TEST_COLUMN_NAMES]); }); it('should open the context view with the filters disabled', async () => { diff --git a/test/functional/apps/dashboard/bwc_import.ts b/test/functional/apps/dashboard/bwc_import.ts deleted file mode 100644 index ebb9d2b99ffa7..0000000000000 --- a/test/functional/apps/dashboard/bwc_import.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 path from 'path'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['dashboard', 'header', 'settings', 'savedObjects', 'common']); - const dashboardExpect = getService('dashboardExpect'); - // Legacy imports are no longer supported https://github.com/elastic/kibana/issues/103921 - describe.skip('bwc import', function describeIndexTests() { - before(async function () { - await PageObjects.dashboard.initTests(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); - await PageObjects.savedObjects.importFile( - path.join(__dirname, 'exports', 'dashboard_6_0_1.json') - ); - await PageObjects.settings.associateIndexPattern( - 'dd684000-8255-11eb-a5e7-93c302c8f329', - 'logstash-*' - ); - await PageObjects.savedObjects.clickConfirmChanges(); - await PageObjects.savedObjects.clickImportDone(); - await PageObjects.common.navigateToApp('dashboard'); - }); - - describe('6.0.1 dashboard', () => { - it('loads an imported dashboard', async function () { - await PageObjects.dashboard.gotoDashboardLandingPage(); - await PageObjects.dashboard.loadSavedDashboard('My custom bwc dashboard'); - await PageObjects.header.waitUntilLoadingHasFinished(); - - await dashboardExpect.metricValuesExist(['14,004']); - }); - }); - }); -} diff --git a/test/functional/apps/dashboard/index.ts b/test/functional/apps/dashboard/index.ts index 8627a258869bb..c9a62447f223a 100644 --- a/test/functional/apps/dashboard/index.ts +++ b/test/functional/apps/dashboard/index.ts @@ -49,7 +49,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_unsaved_state')); loadTestFile(require.resolve('./dashboard_unsaved_listing')); loadTestFile(require.resolve('./edit_visualizations')); - loadTestFile(require.resolve('./time_zones')); loadTestFile(require.resolve('./dashboard_options')); loadTestFile(require.resolve('./data_shared_attributes')); loadTestFile(require.resolve('./share')); @@ -95,7 +94,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./dashboard_time_picker')); loadTestFile(require.resolve('./bwc_shared_urls')); - loadTestFile(require.resolve('./bwc_import')); loadTestFile(require.resolve('./panel_replacing')); loadTestFile(require.resolve('./panel_cloning')); loadTestFile(require.resolve('./copy_panel_to')); diff --git a/test/functional/apps/dashboard/time_zones.ts b/test/functional/apps/dashboard/time_zones.ts deleted file mode 100644 index f60792b3f292a..0000000000000 --- a/test/functional/apps/dashboard/time_zones.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 path from 'path'; -import expect from '@kbn/expect'; - -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getService, getPageObjects }: FtrProviderContext) { - const pieChart = getService('pieChart'); - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects([ - 'dashboard', - 'timePicker', - 'settings', - 'common', - 'savedObjects', - ]); - // Legacy imports are no longer supported https://github.com/elastic/kibana/issues/103921 - describe.skip('dashboard time zones', function () { - this.tags('includeFirefox'); - - before(async () => { - await esArchiver.load('test/functional/fixtures/es_archiver/dashboard/current/kibana'); - await kibanaServer.uiSettings.replace({ - defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', - }); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); - await PageObjects.savedObjects.importFile( - path.join(__dirname, 'exports', 'timezonetest_6_2_4.json') - ); - await PageObjects.savedObjects.checkImportSucceeded(); - await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.preserveCrossAppState(); - await PageObjects.dashboard.loadSavedDashboard('time zone test'); - }); - - after(async () => { - await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'UTC' }); - }); - - it('Exported dashboard adjusts EST time to UTC', async () => { - const time = await PageObjects.timePicker.getTimeConfigAsAbsoluteTimes(); - expect(time.start).to.be('Apr 10, 2018 @ 03:00:00.000'); - expect(time.end).to.be('Apr 10, 2018 @ 04:00:00.000'); - await pieChart.expectPieSliceCount(4); - }); - - it('Changing timezone changes dashboard timestamp and shows the same data', async () => { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSettings(); - await PageObjects.settings.setAdvancedSettingsSelect('dateFormat:tz', 'Etc/GMT+5'); - await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.loadSavedDashboard('time zone test'); - const time = await PageObjects.timePicker.getTimeConfigAsAbsoluteTimes(); - expect(time.start).to.be('Apr 9, 2018 @ 22:00:00.000'); - expect(time.end).to.be('Apr 9, 2018 @ 23:00:00.000'); - await pieChart.expectPieSliceCount(4); - }); - }); -} diff --git a/test/functional/apps/discover/_data_grid.ts b/test/functional/apps/discover/_data_grid.ts index 4a343fb30384e..198691f3b8477 100644 --- a/test/functional/apps/discover/_data_grid.ts +++ b/test/functional/apps/discover/_data_grid.ts @@ -39,19 +39,19 @@ export default function ({ const getTitles = async () => (await testSubjects.getVisibleText('dataGridHeader')).replace(/\s|\r?\n|\r/g, ' '); - expect(await getTitles()).to.be('Time (@timestamp) Document'); + expect(await getTitles()).to.be('@timestamp Document'); await PageObjects.discover.clickFieldListItemAdd('bytes'); - expect(await getTitles()).to.be('Time (@timestamp) bytes'); + expect(await getTitles()).to.be('@timestamp bytes'); await PageObjects.discover.clickFieldListItemAdd('agent'); - expect(await getTitles()).to.be('Time (@timestamp) bytes agent'); + expect(await getTitles()).to.be('@timestamp bytes agent'); await PageObjects.discover.clickFieldListItemRemove('bytes'); - expect(await getTitles()).to.be('Time (@timestamp) agent'); + expect(await getTitles()).to.be('@timestamp agent'); await PageObjects.discover.clickFieldListItemRemove('agent'); - expect(await getTitles()).to.be('Time (@timestamp) Document'); + expect(await getTitles()).to.be('@timestamp Document'); }); }); } diff --git a/test/functional/apps/discover/_data_grid_context.ts b/test/functional/apps/discover/_data_grid_context.ts index 3d9e01e1dee19..d12ada2070cff 100644 --- a/test/functional/apps/discover/_data_grid_context.ts +++ b/test/functional/apps/discover/_data_grid_context.ts @@ -76,7 +76,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should open the context view with the same columns', async () => { const columnNames = await dataGrid.getHeaderFields(); - expect(columnNames).to.eql(['Time (@timestamp)', ...TEST_COLUMN_NAMES]); + expect(columnNames).to.eql(['@timestamp', ...TEST_COLUMN_NAMES]); }); it('should open the context view with the filters disabled', async () => { diff --git a/test/functional/apps/discover/_data_grid_field_data.ts b/test/functional/apps/discover/_data_grid_field_data.ts index 94e8e942f86ba..91c2d5914732d 100644 --- a/test/functional/apps/discover/_data_grid_field_data.ts +++ b/test/functional/apps/discover/_data_grid_field_data.ts @@ -59,8 +59,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it('doc view should show Time and _source columns', async function () { - const expectedHeader = 'Time (@timestamp) Document'; + it('doc view should show @timestamp and _source columns', async function () { + const expectedHeader = '@timestamp Document'; const DocHeader = await dataGrid.getHeaderFields(); expect(DocHeader.join(' ')).to.be(expectedHeader); }); diff --git a/test/functional/apps/discover/_field_data.ts b/test/functional/apps/discover/_field_data.ts index 27407e9a0bc4d..28f147eeab55f 100644 --- a/test/functional/apps/discover/_field_data.ts +++ b/test/functional/apps/discover/_field_data.ts @@ -107,8 +107,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async function () { await kibanaServer.uiSettings.replace({}); }); - it('doc view should show Time and _source columns', async function () { - const expectedHeader = 'Time\n_source'; + it('doc view should show @timestamp and _source columns', async function () { + const expectedHeader = '@timestamp\n_source'; const docHeader = await find.byCssSelector('thead > tr:nth-child(1)'); const docHeaderText = await docHeader.getVisibleText(); expect(docHeaderText).to.be(expectedHeader); diff --git a/test/functional/apps/discover/_field_data_with_fields_api.ts b/test/functional/apps/discover/_field_data_with_fields_api.ts index 666377ae7f794..f0dedb155fc9b 100644 --- a/test/functional/apps/discover/_field_data_with_fields_api.ts +++ b/test/functional/apps/discover/_field_data_with_fields_api.ts @@ -64,9 +64,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - it('doc view should show Time and Document columns', async function () { + it('doc view should show @timestamp and Document columns', async function () { const Docheader = await PageObjects.discover.getDocHeader(); - expect(Docheader).to.contain('Time'); + expect(Docheader).to.contain('@timestamp'); expect(Docheader).to.contain('Document'); }); diff --git a/test/functional/apps/visualize/_timelion.ts b/test/functional/apps/visualize/_timelion.ts index bb85b6821df31..c531ada8a2573 100644 --- a/test/functional/apps/visualize/_timelion.ts +++ b/test/functional/apps/visualize/_timelion.ts @@ -257,7 +257,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(value).to.eql('.es()'); }); - describe('dynamic suggestions for argument values', () => { + // FLAKY: https://github.com/elastic/kibana/issues/116033 + describe.skip('dynamic suggestions for argument values', () => { describe('.es()', () => { it('should show index pattern suggestions for index argument', async () => { await monacoEditor.setCodeEditorValue(''); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index 2300143925b1e..1254d86e99066 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -8,7 +8,7 @@ import axios from 'axios'; import { createExternalService } from './service'; -import * as utils from '../lib/axios_utils'; +import { request, createAxiosResponse } from '../lib/axios_utils'; import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; @@ -29,10 +29,10 @@ jest.mock('../lib/axios_utils', () => { }); axios.create = jest.fn(() => axios); -const requestMock = utils.request as jest.Mock; +const requestMock = request as jest.Mock; const configurationUtilities = actionsConfigMock.create(); -const issueTypesResponse = { +const issueTypesResponse = createAxiosResponse({ data: { projects: [ { @@ -49,9 +49,9 @@ const issueTypesResponse = { }, ], }, -}; +}); -const fieldsResponse = { +const fieldsResponse = createAxiosResponse({ data: { projects: [ { @@ -98,7 +98,7 @@ const fieldsResponse = { }, ], }, -}; +}); const issueResponse = { id: '10267', @@ -108,6 +108,31 @@ const issueResponse = { const issuesResponse = [issueResponse]; +const mockNewAPI = () => + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + }) + ); + +const mockOldAPI = () => + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, + }, + }) + ); + describe('Jira service', () => { let service: ExternalService; @@ -183,18 +208,34 @@ describe('Jira service', () => { }); describe('getIncident', () => { + const axiosRes = { + data: { + id: '1', + key: 'CK-1', + fields: { + summary: 'title', + description: 'description', + created: '2021-10-20T19:41:02.754+0300', + updated: '2021-10-20T19:41:02.754+0300', + }, + }, + }; + test('it returns the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, - })); + requestMock.mockImplementation(() => createAxiosResponse(axiosRes)); const res = await service.getIncident('1'); - expect(res).toEqual({ id: '1', key: 'CK-1', summary: 'title', description: 'description' }); + expect(res).toEqual({ + id: '1', + key: 'CK-1', + summary: 'title', + description: 'description', + created: '2021-10-20T19:41:02.754+0300', + updated: '2021-10-20T19:41:02.754+0300', + }); }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { id: '1', key: 'CK-1' }, - })); + requestMock.mockImplementation(() => createAxiosResponse(axiosRes)); await service.getIncident('1'); expect(requestMock).toHaveBeenCalledWith({ @@ -215,9 +256,38 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get incident with id 1. Error: An error has occurred Reason: Required field' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ ...axiosRes, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIncident('1')).rejects.toThrow( + '[Action][Jira]: Unable to get incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.getIncident('1')).rejects.toThrow( + '[Action][Jira]: Unable to get incident with id 1. Error: Response is missing at least one of the expected fields: id,key Reason: unknown: errorResponse was null' + ); + }); }); describe('createIncident', () => { + const incident = { + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + parent: 'RJ-107', + }, + }; + test('it creates the incident correctly', async () => { /* The response from Jira when creating an issue contains only the key and the id. The function makes the following calls when creating an issue: @@ -225,24 +295,19 @@ describe('Jira service', () => { 2. Create the issue. 3. Get the created issue with all the necessary fields. */ - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + }) + ); - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + }) + ); - const res = await service.createIncident({ - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: null, - }, - }); + const res = await service.createIncident(incident); expect(res).toEqual({ title: 'CK-1', @@ -260,24 +325,30 @@ describe('Jira service', () => { 3. Get the created issue with all the necessary fields. */ // getIssueType mocks - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, }, - }, - })); + }) + ); // getIssueType mocks requestMock.mockImplementationOnce(() => issueTypesResponse); - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + }) + ); - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + }) + ); const res = await service.createIncident({ incident: { @@ -317,25 +388,31 @@ describe('Jira service', () => { }); test('removes newline characters and trialing spaces from summary', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', + }, }, - }, - })); + }) + ); // getIssueType mocks requestMock.mockImplementationOnce(() => issueTypesResponse); // getIssueType mocks - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'test', description: 'description' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { summary: 'test', description: 'description' } }, + }) + ); - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + }) + ); await service.createIncident({ incident: { @@ -368,24 +445,17 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - fields: { created: '2020-04-27T10:59:46.202Z' }, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + fields: { created: '2020-04-27T10:59:46.202Z' }, + }, + }) + ); - await service.createIncident({ - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', - }, - }); + await service.createIncident(incident); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -414,44 +484,55 @@ describe('Jira service', () => { throw error; }); - await expect( - service.createIncident({ - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: null, - }, - }) - ).rejects.toThrow( + await expect(service.createIncident(incident)).rejects.toThrow( '[Action][Jira]: Unable to create incident. Error: An error has occurred. Reason: Required field' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createIncident(incident)).rejects.toThrow( + '[Action][Jira]: Unable to create incident. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.createIncident(incident)).rejects.toThrow( + '[Action][Jira]: Unable to create incident. Error: Response is missing at least one of the expected fields: id. Reason: unknown: errorResponse was null' + ); + }); }); describe('updateIncident', () => { + const incident = { + incidentId: '1', + incident: { + summary: 'title', + description: 'desc', + labels: [], + issueType: '10006', + priority: 'High', + parent: 'RJ-107', + }, + }; + test('it updates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - fields: { updated: '2020-04-27T10:59:46.202Z' }, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + fields: { updated: '2020-04-27T10:59:46.202Z' }, + }, + }) + ); - const res = await service.updateIncident({ - incidentId: '1', - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: null, - }, - }); + const res = await service.updateIncident(incident); expect(res).toEqual({ title: 'CK-1', @@ -462,25 +543,17 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - fields: { updated: '2020-04-27T10:59:46.202Z' }, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + fields: { updated: '2020-04-27T10:59:46.202Z' }, + }, + }) + ); - await service.updateIncident({ - incidentId: '1', - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', - }, - }); + await service.updateIncident(incident); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -509,41 +582,42 @@ describe('Jira service', () => { throw error; }); - await expect( - service.updateIncident({ - incidentId: '1', - incident: { - summary: 'title', - description: 'desc', - labels: [], - issueType: '10006', - priority: 'High', - parent: null, - }, - }) - ).rejects.toThrow( + await expect(service.updateIncident(incident)).rejects.toThrow( '[Action][Jira]: Unable to update incident with id 1. Error: An error has occurred. Reason: Required field' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.updateIncident(incident)).rejects.toThrow( + '[Action][Jira]: Unable to update incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('createComment', () => { + const commentReq = { + incidentId: '1', + comment: { + comment: 'comment', + commentId: 'comment-1', + }, + }; test('it creates the comment correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - created: '2020-04-27T10:59:46.202Z', - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + created: '2020-04-27T10:59:46.202Z', + }, + }) + ); - const res = await service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }); + const res = await service.createComment(commentReq); expect(res).toEqual({ commentId: 'comment-1', @@ -553,21 +627,17 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - created: '2020-04-27T10:59:46.202Z', - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + key: 'CK-1', + created: '2020-04-27T10:59:46.202Z', + }, + }) + ); - await service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }); + await service.createComment(commentReq); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -586,29 +656,33 @@ describe('Jira service', () => { throw error; }); - await expect( - service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }) - ).rejects.toThrow( + await expect(service.createComment(commentReq)).rejects.toThrow( '[Action][Jira]: Unable to create comment at incident with id 1. Error: An error has occurred. Reason: Required field' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createComment(commentReq)).rejects.toThrow( + '[Action][Jira]: Unable to create comment at incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.createComment(commentReq)).rejects.toThrow( + '[Action][Jira]: Unable to create comment at incident with id 1. Error: Response is missing at least one of the expected fields: id,created. Reason: unknown: errorResponse was null' + ); + }); }); describe('getCapabilities', () => { test('it should return the capabilities', async () => { - requestMock.mockImplementation(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); const res = await service.getCapabilities(); expect(res).toEqual({ capabilities: { @@ -618,13 +692,7 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); await service.getCapabilities(); @@ -649,16 +717,34 @@ describe('Jira service', () => { ); }); - test('it should throw an auth error', async () => { + test('it should return unknown if the error is a string', async () => { requestMock.mockImplementation(() => { const error = new Error('An error has occurred'); - // @ts-ignore this can happen! + // @ts-ignore error.response = { data: 'Unauthorized' }; throw error; }); await expect(service.getCapabilities()).rejects.toThrow( - '[Action][Jira]: Unable to get capabilities. Error: An error has occurred. Reason: Unauthorized' + '[Action][Jira]: Unable to get capabilities. Error: An error has occurred. Reason: unknown: errorResponse.errors was null' + ); + }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getCapabilities()).rejects.toThrow( + '[Action][Jira]: Unable to get capabilities. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.getCapabilities()).rejects.toThrow( + '[Action][Jira]: Unable to get capabilities. Error: Response is missing at least one of the expected fields: capabilities. Reason: unknown: errorResponse was null' ); }); }); @@ -666,13 +752,7 @@ describe('Jira service', () => { describe('getIssueTypes', () => { describe('Old API', () => { test('it should return the issue types', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementationOnce(() => issueTypesResponse); @@ -691,13 +771,7 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementationOnce(() => issueTypesResponse); @@ -713,13 +787,7 @@ describe('Jira service', () => { }); test('it should throw an error', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); @@ -731,25 +799,30 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + mockOldAPI(); + + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIssueTypes()).rejects.toThrow( + '[Action][Jira]: Unable to get issue types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('New API', () => { test('it should return the issue types', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); + mockNewAPI(); - requestMock.mockImplementationOnce(() => ({ - data: { - values: issueTypesResponse.data.projects[0].issuetypes, - }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: issueTypesResponse.data.projects[0].issuetypes, + }, + }) + ); const res = await service.getIssueTypes(); @@ -766,22 +839,15 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); + mockNewAPI(); - requestMock.mockImplementationOnce(() => ({ - data: { - values: issueTypesResponse.data.projects[0].issuetypes, - }, - })); + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: issueTypesResponse.data.projects[0].issuetypes, + }, + }) + ); await service.getIssueTypes(); @@ -795,16 +861,7 @@ describe('Jira service', () => { }); test('it should throw an error', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); + mockNewAPI(); requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); @@ -816,19 +873,25 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get issue types. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + mockNewAPI(); + + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIssueTypes()).rejects.toThrow( + '[Action][Jira]: Unable to get issue types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); }); describe('getFieldsByIssueType', () => { describe('Old API', () => { test('it should return the fields', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementationOnce(() => fieldsResponse); @@ -857,13 +920,7 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementationOnce(() => fieldsResponse); @@ -879,13 +936,7 @@ describe('Jira service', () => { }); test('it should throw an error', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - navigation: 'https://siem-kibana.atlassian.net/rest/capabilities/navigation', - }, - }, - })); + mockOldAPI(); requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); @@ -897,43 +948,48 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get fields' ); }); + + test('it should throw if the request is not a JSON', async () => { + mockOldAPI(); + + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getFieldsByIssueType('10006')).rejects.toThrow( + '[Action][Jira]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('New API', () => { test('it should return the fields', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); - - requestMock.mockImplementationOnce(() => ({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { - required: false, - schema: { type: 'string' }, - fieldId: 'priority', - allowedValues: [ - { + mockNewAPI(); + + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { + required: false, + schema: { type: 'string' }, + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3', }, - ], - defaultValue: { - name: 'Medium', - id: '3', }, - }, - ], - }, - })); + ], + }, + }) + ); const res = await service.getFieldsByIssueType('10006'); @@ -954,39 +1010,32 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); - - requestMock.mockImplementationOnce(() => ({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { - required: true, - schema: { type: 'string' }, - fieldId: 'priority', - allowedValues: [ - { + mockNewAPI(); + + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { + required: true, + schema: { type: 'string' }, + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3', }, - ], - defaultValue: { - name: 'Medium', - id: '3', }, - }, - ], - }, - })); + ], + }, + }) + ); await service.getFieldsByIssueType('10006'); @@ -1000,16 +1049,7 @@ describe('Jira service', () => { }); test('it should throw an error', async () => { - requestMock.mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', - }, - }, - })); + mockNewAPI(); requestMock.mockImplementation(() => { const error: ResponseError = new Error('An error has occurred'); @@ -1021,16 +1061,30 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get fields. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + mockNewAPI(); + + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getFieldsByIssueType('10006')).rejects.toThrow( + '[Action][Jira]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); }); describe('getIssues', () => { test('it should return the issues', async () => { - requestMock.mockImplementation(() => ({ - data: { - issues: issuesResponse, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + issues: issuesResponse, + }, + }) + ); const res = await service.getIssues('Test title'); @@ -1044,11 +1098,13 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - issues: issuesResponse, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + issues: issuesResponse, + }, + }) + ); await service.getIssues('Test title'); expect(requestMock).toHaveBeenLastCalledWith({ @@ -1071,13 +1127,25 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get issues. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIssues('Test title')).rejects.toThrow( + '[Action][Jira]: Unable to get issues. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('getIssue', () => { test('it should return a single issue', async () => { - requestMock.mockImplementation(() => ({ - data: issueResponse, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: issueResponse, + }) + ); const res = await service.getIssue('RJ-107'); @@ -1089,11 +1157,13 @@ describe('Jira service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - issues: issuesResponse, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + issues: issuesResponse, + }, + }) + ); await service.getIssue('RJ-107'); expect(requestMock).toHaveBeenLastCalledWith({ @@ -1116,81 +1186,105 @@ describe('Jira service', () => { '[Action][Jira]: Unable to get issue with id RJ-107. Error: An error has occurred. Reason: Could not get issue types' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIssue('Test title')).rejects.toThrow( + '[Action][Jira]: Unable to get issue with id Test title. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown: errorResponse was null' + ); + }); }); describe('getFields', () => { const callMocks = () => { requestMock - .mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, }, - }, - })) - .mockImplementationOnce(() => ({ - data: { - values: issueTypesResponse.data.projects[0].issuetypes, - }, - })) - .mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: issueTypesResponse.data.projects[0].issuetypes, }, - }, - })) - .mockImplementationOnce(() => ({ - data: { - capabilities: { - 'list-project-issuetypes': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', - 'list-issuetype-fields': - 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, }, - }, - })) - .mockImplementationOnce(() => ({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { required: true, schema: { type: 'string' }, fieldId: 'description' }, - { - required: false, - schema: { type: 'string' }, - fieldId: 'priority', - allowedValues: [ - { + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + capabilities: { + 'list-project-issuetypes': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-project-issuetypes', + 'list-issuetype-fields': + 'https://siem-kibana.atlassian.net/rest/capabilities/list-issuetype-fields', + }, + }, + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { required: true, schema: { type: 'string' }, fieldId: 'description' }, + { + required: false, + schema: { type: 'string' }, + fieldId: 'priority', + allowedValues: [ + { + name: 'Medium', + id: '3', + }, + ], + defaultValue: { name: 'Medium', id: '3', }, - ], - defaultValue: { - name: 'Medium', - id: '3', }, - }, - ], - }, - })) - .mockImplementationOnce(() => ({ - data: { - values: [ - { required: true, schema: { type: 'string' }, fieldId: 'summary' }, - { required: true, schema: { type: 'string' }, fieldId: 'description' }, - ], - }, - })); + ], + }, + }) + ) + .mockImplementationOnce(() => + createAxiosResponse({ + data: { + values: [ + { required: true, schema: { type: 'string' }, fieldId: 'summary' }, + { required: true, schema: { type: 'string' }, fieldId: 'description' }, + ], + }, + }) + ); }; + beforeEach(() => { jest.resetAllMocks(); }); + test('it should call request with correct arguments', async () => { callMocks(); await service.getFields(); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index be0240e705a65..a3262a526e2f4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -27,7 +27,7 @@ import { } from './types'; import * as i18n from './translations'; -import { request, getErrorMessage } from '../lib/axios_utils'; +import { request, getErrorMessage, throwIfResponseIsNotValid } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; const VERSION = '2'; @@ -111,19 +111,15 @@ export const createExternalService = ( .filter((item) => !isEmpty(item)) .join(', '); - const createErrorMessage = (errorResponse: ResponseError | string | null | undefined): string => { + const createErrorMessage = (errorResponse: ResponseError | null | undefined): string => { if (errorResponse == null) { - return ''; - } - if (typeof errorResponse === 'string') { - // Jira error.response.data can be string!! - return errorResponse; + return 'unknown: errorResponse was null'; } const { errorMessages, errors } = errorResponse; if (errors == null) { - return ''; + return 'unknown: errorResponse.errors was null'; } if (Array.isArray(errorMessages) && errorMessages.length > 0) { @@ -185,9 +181,14 @@ export const createExternalService = ( configurationUtilities, }); - const { fields, ...rest } = res.data; + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'key'], + }); + + const { fields, id: incidentId, key } = res.data; - return { ...rest, ...fields }; + return { id: incidentId, key, created: fields.created, updated: fields.updated, ...fields }; } catch (error) { throw new Error( getErrorMessage( @@ -234,6 +235,11 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id'], + }); + const updatedIncident = await getIncident(res.data.id); return { @@ -266,7 +272,7 @@ export const createExternalService = ( const fields = createFields(projectKey, incidentWithoutNullValues); try { - await request({ + const res = await request({ axios: axiosInstance, method: 'put', url: `${incidentUrl}/${incidentId}`, @@ -275,6 +281,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const updatedIncident = await getIncident(incidentId as string); return { @@ -309,6 +319,11 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'created'], + }); + return { commentId: comment.commentId, externalCommentId: res.data.id, @@ -336,6 +351,11 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['capabilities'], + }); + return { ...res.data }; } catch (error) { throw new Error( @@ -362,6 +382,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const issueTypes = res.data.projects[0]?.issuetypes ?? []; return normalizeIssueTypes(issueTypes); } else { @@ -373,6 +397,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const issueTypes = res.data.values; return normalizeIssueTypes(issueTypes); } @@ -401,6 +429,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const fields = res.data.projects[0]?.issuetypes[0]?.fields || {}; return normalizeFields(fields); } else { @@ -412,6 +444,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const fields = res.data.values.reduce( (acc: { [x: string]: {} }, value: { fieldId: string }) => ({ ...acc, @@ -471,6 +507,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + return normalizeSearchResults(res.data?.issues ?? []); } catch (error) { throw new Error( @@ -495,6 +535,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + return normalizeIssue(res.data ?? {}); } catch (error) { throw new Error( diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts index 287f74c6bc703..d0177e0e5a8a2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts @@ -10,7 +10,14 @@ import { Agent as HttpsAgent } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; -import { addTimeZoneToDate, request, patch, getErrorMessage } from './axios_utils'; +import { + addTimeZoneToDate, + request, + patch, + getErrorMessage, + throwIfResponseIsNotValid, + createAxiosResponse, +} from './axios_utils'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsConfigMock } from '../../actions_config.mock'; import { getCustomAgents } from './get_custom_agents'; @@ -292,3 +299,82 @@ describe('getErrorMessage', () => { expect(msg).toBe('[Action][My connector name]: An error has occurred'); }); }); + +describe('throwIfResponseIsNotValid', () => { + const res = createAxiosResponse({ + headers: { ['content-type']: 'application/json' }, + data: { incident: { id: '1' } }, + }); + + test('it does NOT throw if the request is valid', () => { + expect(() => throwIfResponseIsNotValid({ res })).not.toThrow(); + }); + + test('it does throw if the content-type is not json', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, headers: { ['content-type']: 'text/html' } }, + }) + ).toThrow( + 'Unsupported content type: text/html in GET https://example.com. Supported content types: application/json' + ); + }); + + test('it does throw if the content-type is undefined', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, headers: {} }, + }) + ).toThrow( + 'Unsupported content type: undefined in GET https://example.com. Supported content types: application/json' + ); + }); + + test('it does throw if the data is not an object or array', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data: 'string' }, + }) + ).toThrow('Response is not a valid JSON'); + }); + + test('it does NOT throw if the data is an array', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data: ['test'] }, + }) + ).not.toThrow(); + }); + + test.each(['', [], {}])('it does NOT throw if the data is %p', (data) => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data }, + }) + ).not.toThrow(); + }); + + test('it does throw if the required attribute is not in the response', () => { + expect(() => + throwIfResponseIsNotValid({ res, requiredAttributesToBeInTheResponse: ['not-exist'] }) + ).toThrow('Response is missing at least one of the expected fields: not-exist'); + }); + + test('it does throw if the required attribute are defined and the data is an array', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data: ['test'] }, + requiredAttributesToBeInTheResponse: ['not-exist'], + }) + ).toThrow('Response is missing at least one of the expected fields: not-exist'); + }); + + test('it does NOT throw if the value of the required attribute is null', () => { + expect(() => + throwIfResponseIsNotValid({ + res: { ...res, data: { id: null } }, + requiredAttributesToBeInTheResponse: ['id'], + }) + ).not.toThrow(); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts index af353e1d1da5a..43c9d276e6574 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { isObjectLike, isEmpty } from 'lodash'; import { AxiosInstance, Method, AxiosResponse, AxiosBasicCredentials } from 'axios'; import { Logger } from '../../../../../../src/core/server'; import { getCustomAgents } from './get_custom_agents'; @@ -76,3 +77,70 @@ export const addTimeZoneToDate = (date: string, timezone = 'GMT'): string => { export const getErrorMessage = (connector: string, msg: string) => { return `[Action][${connector}]: ${msg}`; }; + +export const throwIfResponseIsNotValid = ({ + res, + requiredAttributesToBeInTheResponse = [], +}: { + res: AxiosResponse; + requiredAttributesToBeInTheResponse?: string[]; +}) => { + const requiredContentType = 'application/json'; + const contentType = res.headers['content-type'] ?? 'undefined'; + const data = res.data; + + /** + * Check that the content-type of the response is application/json. + * Then includes is added because the header can be application/json;charset=UTF-8. + */ + if (!contentType.includes(requiredContentType)) { + throw new Error( + `Unsupported content type: ${contentType} in ${res.config.method} ${res.config.url}. Supported content types: ${requiredContentType}` + ); + } + + /** + * Check if the response is a JS object (data != null && typeof data === 'object') + * in case the content type is application/json but for some reason the response is not. + * Empty responses (204 No content) are ignored because the typeof data will be string and + * isObjectLike will fail. + * Axios converts automatically JSON to JS objects. + */ + if (!isEmpty(data) && !isObjectLike(data)) { + throw new Error('Response is not a valid JSON'); + } + + if (requiredAttributesToBeInTheResponse.length > 0) { + const requiredAttributesError = new Error( + `Response is missing at least one of the expected fields: ${requiredAttributesToBeInTheResponse.join( + ',' + )}` + ); + + /** + * If the response is an array and requiredAttributesToBeInTheResponse + * are not empty then we thrown an error assuming that the consumer + * expects an object response and not an array. + */ + + if (Array.isArray(data)) { + throw requiredAttributesError; + } + + requiredAttributesToBeInTheResponse.forEach((attr) => { + // Check only for undefined as null is a valid value + if (data[attr] === undefined) { + throw requiredAttributesError; + } + }); + } +}; + +export const createAxiosResponse = (res: Partial): AxiosResponse => ({ + data: {}, + status: 200, + statusText: 'OK', + headers: { ['content-type']: 'application/json' }, + config: { method: 'GET', url: 'https://example.com' }, + ...res, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts index ba55543386225..094b8150850df 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -8,7 +8,7 @@ import axios from 'axios'; import { createExternalService, getValueTextContent, formatUpdateRequest } from './service'; -import * as utils from '../lib/axios_utils'; +import { request, createAxiosResponse } from '../lib/axios_utils'; import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; @@ -27,7 +27,7 @@ jest.mock('../lib/axios_utils', () => { }); axios.create = jest.fn(() => axios); -const requestMock = utils.request as jest.Mock; +const requestMock = request as jest.Mock; const now = Date.now; const TIMESTAMP = 1589391874472; const configurationUtilities = actionsConfigMock.create(); @@ -38,44 +38,50 @@ const configurationUtilities = actionsConfigMock.create(); // b) Update the incident // c) Get the updated incident const mockIncidentUpdate = (withUpdateError = false) => { - requestMock.mockImplementationOnce(() => ({ - data: { - id: '1', - name: 'title', - description: { - format: 'html', - content: 'description', + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title', + description: { + format: 'html', + content: 'description', + }, + incident_type_ids: [1001, 16, 12], + severity_code: 6, }, - incident_type_ids: [1001, 16, 12], - severity_code: 6, - }, - })); + }) + ); if (withUpdateError) { requestMock.mockImplementationOnce(() => { throw new Error('An error has occurred'); }); } else { - requestMock.mockImplementationOnce(() => ({ + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + success: true, + id: '1', + inc_last_modified_date: 1589391874472, + }, + }) + ); + } + + requestMock.mockImplementationOnce(() => + createAxiosResponse({ data: { - success: true, id: '1', + name: 'title_updated', + description: { + format: 'html', + content: 'desc_updated', + }, inc_last_modified_date: 1589391874472, }, - })); - } - - requestMock.mockImplementationOnce(() => ({ - data: { - id: '1', - name: 'title_updated', - description: { - format: 'html', - content: 'desc_updated', - }, - inc_last_modified_date: 1589391874472, - }, - })); + }) + ); }; describe('IBM Resilient service', () => { @@ -207,24 +213,28 @@ describe('IBM Resilient service', () => { describe('getIncident', () => { test('it returns the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - name: '1', - description: { - format: 'html', - content: 'description', + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + name: '1', + description: { + format: 'html', + content: 'description', + }, }, - }, - })); + }) + ); const res = await service.getIncident('1'); expect(res).toEqual({ id: '1', name: '1', description: 'description' }); }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { id: '1' }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { id: '1' }, + }) + ); await service.getIncident('1'); expect(requestMock).toHaveBeenCalledWith({ @@ -246,28 +256,42 @@ describe('IBM Resilient service', () => { 'Unable to get incident with id 1. Error: An error has occurred' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIncident('1')).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); }); describe('createIncident', () => { + const incident = { + incident: { + name: 'title', + description: 'desc', + incidentTypes: [1001], + severityCode: 6, + }, + }; + test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - name: 'title', - description: 'description', - discovered_date: 1589391874472, - create_date: 1589391874472, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + }) + ); - const res = await service.createIncident({ - incident: { - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }, - }); + const res = await service.createIncident(incident); expect(res).toEqual({ title: '1', @@ -278,24 +302,19 @@ describe('IBM Resilient service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - name: 'title', - description: 'description', - discovered_date: 1589391874472, - create_date: 1589391874472, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + }) + ); - await service.createIncident({ - incident: { - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }, - }); + await service.createIncident(incident); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -334,20 +353,39 @@ describe('IBM Resilient service', () => { '[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createIncident(incident)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.createIncident(incident)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: Response is missing at least one of the expected fields: id,create_date.' + ); + }); }); describe('updateIncident', () => { + const req = { + incidentId: '1', + incident: { + name: 'title', + description: 'desc', + incidentTypes: [1001], + severityCode: 6, + }, + }; test('it updates the incident correctly', async () => { mockIncidentUpdate(); - const res = await service.updateIncident({ - incidentId: '1', - incident: { - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }, - }); + const res = await service.updateIncident(req); expect(res).toEqual({ title: '1', @@ -430,38 +468,59 @@ describe('IBM Resilient service', () => { test('it should throw an error', async () => { mockIncidentUpdate(true); - await expect( - service.updateIncident({ - incidentId: '1', - incident: { + await expect(service.updateIncident(req)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' + ); + }); + + test('it should throw if the request is not a JSON', async () => { + // get incident request + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + id: '1', name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 5, + description: { + format: 'html', + content: 'description', + }, + incident_type_ids: [1001, 16, 12], + severity_code: 6, }, }) - ).rejects.toThrow( - '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' + ); + + // update incident request + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.updateIncident(req)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json' ); }); }); describe('createComment', () => { + const req = { + incidentId: '1', + comment: { + comment: 'comment', + commentId: 'comment-1', + }, + }; + test('it creates the comment correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - create_date: 1589391874472, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + create_date: 1589391874472, + }, + }) + ); - const res = await service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }); + const res = await service.createComment(req); expect(res).toEqual({ commentId: 'comment-1', @@ -471,20 +530,16 @@ describe('IBM Resilient service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - create_date: 1589391874472, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + create_date: 1589391874472, + }, + }) + ); - await service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }); + await service.createComment(req); expect(requestMock).toHaveBeenCalledWith({ axios, @@ -506,27 +561,31 @@ describe('IBM Resilient service', () => { throw new Error('An error has occurred'); }); - await expect( - service.createComment({ - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }) - ).rejects.toThrow( + await expect(service.createComment(req)).rejects.toThrow( '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createComment(req)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); }); describe('getIncidentTypes', () => { test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - values: incidentTypes, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + values: incidentTypes, + }, + }) + ); const res = await service.getIncidentTypes(); @@ -545,15 +604,27 @@ describe('IBM Resilient service', () => { '[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getIncidentTypes()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get incident types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); }); describe('getSeverity', () => { test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - values: severity, - }, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + values: severity, + }, + }) + ); const res = await service.getSeverity(); @@ -578,17 +649,29 @@ describe('IBM Resilient service', () => { throw new Error('An error has occurred'); }); - await expect(service.getIncidentTypes()).rejects.toThrow( - '[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.' + await expect(service.getSeverity()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get severity. Error: An error has occurred.' + ); + }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getSeverity()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get severity. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' ); }); }); describe('getFields', () => { test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data: resilientFields, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: resilientFields, + }) + ); await service.getFields(); expect(requestMock).toHaveBeenCalledWith({ @@ -598,10 +681,13 @@ describe('IBM Resilient service', () => { url: 'https://resilient.elastic.co/rest/orgs/201/types/incident/fields', }); }); + test('it returns common fields correctly', async () => { - requestMock.mockImplementation(() => ({ - data: resilientFields, - })); + requestMock.mockImplementation(() => + createAxiosResponse({ + data: resilientFields, + }) + ); const res = await service.getFields(); expect(res).toEqual(resilientFields); }); @@ -614,5 +700,15 @@ describe('IBM Resilient service', () => { 'Unable to get fields. Error: An error has occurred' ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.getFields()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' + ); + }); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts index 2f385315e4392..a469c631fac37 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -24,7 +24,7 @@ import { } from './types'; import * as i18n from './translations'; -import { getErrorMessage, request } from '../lib/axios_utils'; +import { getErrorMessage, request, throwIfResponseIsNotValid } from '../lib/axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; const VIEW_INCIDENT_URL = `#incidents`; @@ -134,6 +134,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + return { ...res.data, description: res.data.description?.content ?? '' }; } catch (error) { throw new Error( @@ -182,6 +186,11 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'create_date'], + }); + return { title: `${res.data.id}`, id: `${res.data.id}`, @@ -212,6 +221,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + if (!res.data.success) { throw new Error(res.data.message); } @@ -245,6 +258,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + return { commentId: comment.commentId, externalCommentId: res.data.id, @@ -270,6 +287,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const incidentTypes = res.data?.values ?? []; return incidentTypes.map((type: { value: string; label: string }) => ({ id: type.value, @@ -292,6 +313,10 @@ export const createExternalService = ( configurationUtilities, }); + throwIfResponseIsNotValid({ + res, + }); + const incidentTypes = res.data?.values ?? []; return incidentTypes.map((type: { value: string; label: string }) => ({ id: type.value, @@ -312,6 +337,11 @@ export const createExternalService = ( logger, configurationUtilities, }); + + throwIfResponseIsNotValid({ + res, + }); + return res.data ?? []; } catch (error) { throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}.`)); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts index 358af7cd2e9ef..e5a161611fcb1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts @@ -132,7 +132,7 @@ describe('api_sir', () => { }); describe('prepareParams', () => { - test('it prepares the params correctly when the connector is legacy', async () => { + test('it prepares the params correctly when the connector uses the old API', async () => { expect(prepareParams(true, sirParams)).toEqual({ ...sirParams, incident: { @@ -145,7 +145,7 @@ describe('api_sir', () => { }); }); - test('it prepares the params correctly when the connector is not legacy', async () => { + test('it prepares the params correctly when the connector does not uses the old API', async () => { expect(prepareParams(false, sirParams)).toEqual({ ...sirParams, incident: { @@ -158,7 +158,7 @@ describe('api_sir', () => { }); }); - test('it prepares the params correctly when the connector is legacy and the observables are undefined', async () => { + test('it prepares the params correctly when the connector uses the old API and the observables are undefined', async () => { const { dest_ip: destIp, source_ip: sourceIp, @@ -192,7 +192,7 @@ describe('api_sir', () => { const res = await apiSIR.pushToService({ externalService, params, - config: { isLegacy: false }, + config: { usesTableApi: false }, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -221,7 +221,7 @@ describe('api_sir', () => { await apiSIR.pushToService({ externalService, params, - config: { isLegacy: false }, + config: { usesTableApi: false }, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -244,12 +244,12 @@ describe('api_sir', () => { ); }); - test('it does not call bulkAddObservableToIncident if it a legacy connector', async () => { + test('it does not call bulkAddObservableToIncident if the connector uses the old API', async () => { const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } }; await apiSIR.pushToService({ externalService, params, - config: { isLegacy: true }, + config: { usesTableApi: true }, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', @@ -274,7 +274,7 @@ describe('api_sir', () => { await apiSIR.pushToService({ externalService, params, - config: { isLegacy: false }, + config: { usesTableApi: false }, secrets: {}, logger: mockedLogger, commentFieldKey: 'work_notes', diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts index 326bb79a0e708..4e74d79c6f4a0 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts @@ -59,13 +59,13 @@ const observablesToString = (obs: string | string[] | null | undefined): string }; export const prepareParams = ( - isLegacy: boolean, + usesTableApi: boolean, params: PushToServiceApiParamsSIR ): PushToServiceApiParamsSIR => { - if (isLegacy) { + if (usesTableApi) { /** * The schema has change to accept an array of observables - * or a string. In the case of a legacy connector we need to + * or a string. In the case of connector that uses the old API we need to * convert the observables to a string */ return { @@ -81,8 +81,8 @@ export const prepareParams = ( } /** - * For non legacy connectors the observables - * will be added in a different call. + * For connectors that do not use the old API + * the observables will be added in a different call. * They need to be set to null when sending the fields * to ServiceNow */ @@ -108,7 +108,7 @@ const pushToServiceHandler = async ({ }: PushToServiceApiHandlerArgs): Promise => { const res = await api.pushToService({ externalService, - params: prepareParams(!!config.isLegacy, params as PushToServiceApiParamsSIR), + params: prepareParams(!!config.usesTableApi, params as PushToServiceApiParamsSIR), config, secrets, commentFieldKey, @@ -130,7 +130,7 @@ const pushToServiceHandler = async ({ * through the pushToService call. */ - if (!config.isLegacy) { + if (!config.usesTableApi) { const sirExternalService = externalService as ExternalServiceSIR; const obsWithType: Array<[string[], ObservableTypes]> = [ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts index af8d1b9f38b17..e41eea24834c7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -14,7 +14,7 @@ export const ExternalIncidentServiceConfigurationBase = { export const ExternalIncidentServiceConfiguration = { ...ExternalIncidentServiceConfigurationBase, - isLegacy: schema.boolean({ defaultValue: true }), + usesTableApi: schema.boolean({ defaultValue: true }), }; export const ExternalIncidentServiceConfigurationBaseSchema = schema.object( @@ -49,7 +49,7 @@ const CommonAttributes = { externalId: schema.nullable(schema.string()), category: schema.nullable(schema.string()), subcategory: schema.nullable(schema.string()), - correlation_id: schema.nullable(schema.string()), + correlation_id: schema.nullable(schema.string({ defaultValue: DEFAULT_ALERTS_GROUPING_KEY })), correlation_display: schema.nullable(schema.string()), }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index cb030c7bb6933..c90a7222ba10b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -35,7 +35,8 @@ export const createExternalService: ServiceFactory = ( configurationUtilities: ActionsConfigurationUtilities, { table, importSetTable, useImportAPI, appScope }: SNProductsConfigValue ): ExternalService => { - const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType; + const { apiUrl: url, usesTableApi: usesTableApiConfigValue } = + config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; if (!url || !username || !password) { @@ -57,11 +58,11 @@ export const createExternalService: ServiceFactory = ( auth: { username, password }, }); - const useOldApi = !useImportAPI || isLegacy; + const useTableApi = !useImportAPI || usesTableApiConfigValue; - const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl); + const getCreateIncidentUrl = () => (useTableApi ? tableApiIncidentUrl : importSetTableUrl); const getUpdateIncidentUrl = (incidentId: string) => - useOldApi ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl; + useTableApi ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl; const getIncidentViewURL = (id: string) => { // Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html @@ -105,7 +106,7 @@ export const createExternalService: ServiceFactory = ( /** * Gets the Elastic SN Application information including the current version. - * It should not be used on legacy connectors. + * It should not be used on connectors that use the old API. */ const getApplicationInformation = async (): Promise => { try { @@ -129,7 +130,7 @@ export const createExternalService: ServiceFactory = ( logger.debug(`Create incident: Application scope: ${scope}: Application version${version}`); const checkIfApplicationIsInstalled = async () => { - if (!useOldApi) { + if (!useTableApi) { const { version, scope } = await getApplicationInformation(); logApplicationInfo(scope, version); } @@ -180,17 +181,17 @@ export const createExternalService: ServiceFactory = ( url: getCreateIncidentUrl(), logger, method: 'post', - data: prepareIncident(useOldApi, incident), + data: prepareIncident(useTableApi, incident), configurationUtilities, }); checkInstance(res); - if (!useOldApi) { + if (!useTableApi) { throwIfImportSetApiResponseIsAnError(res.data); } - const incidentId = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; + const incidentId = useTableApi ? res.data.result.sys_id : res.data.result[0].sys_id; const insertedIncident = await getIncident(incidentId); return { @@ -212,23 +213,23 @@ export const createExternalService: ServiceFactory = ( axios: axiosInstance, url: getUpdateIncidentUrl(incidentId), // Import Set API supports only POST. - method: useOldApi ? 'patch' : 'post', + method: useTableApi ? 'patch' : 'post', logger, data: { - ...prepareIncident(useOldApi, incident), + ...prepareIncident(useTableApi, incident), // elastic_incident_id is used to update the incident when using the Import Set API. - ...(useOldApi ? {} : { elastic_incident_id: incidentId }), + ...(useTableApi ? {} : { elastic_incident_id: incidentId }), }, configurationUtilities, }); checkInstance(res); - if (!useOldApi) { + if (!useTableApi) { throwIfImportSetApiResponseIsAnError(res.data); } - const id = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id; + const id = useTableApi ? res.data.result.sys_id : res.data.result[0].sys_id; const updatedIncident = await getIncident(id); return { diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts index 7b3f310a99e0e..21bc4894c5717 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.test.ts @@ -10,7 +10,7 @@ import axios from 'axios'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { Logger } from '../../../../../../src/core/server'; import { actionsConfigMock } from '../../actions_config.mock'; -import * as utils from '../lib/axios_utils'; +import { request, createAxiosResponse } from '../lib/axios_utils'; import { createExternalService } from './service'; import { mappings } from './mocks'; import { ExternalService } from './types'; @@ -27,7 +27,7 @@ jest.mock('../lib/axios_utils', () => { }); axios.create = jest.fn(() => axios); -const requestMock = utils.request as jest.Mock; +const requestMock = request as jest.Mock; const configurationUtilities = actionsConfigMock.create(); describe('Swimlane Service', () => { @@ -152,9 +152,7 @@ describe('Swimlane Service', () => { }; test('it creates a record correctly', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); const res = await service.createRecord({ incident, @@ -169,9 +167,7 @@ describe('Swimlane Service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); await service.createRecord({ incident, @@ -207,6 +203,24 @@ describe('Swimlane Service', () => { `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.createRecord({ incident })).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown` + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.createRecord({ incident })).rejects.toThrow( + `[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: Response is missing at least one of the expected fields: id,name,createdDate. Reason: unknown` + ); + }); }); describe('updateRecord', () => { @@ -218,9 +232,7 @@ describe('Swimlane Service', () => { const incidentId = '123'; test('it updates a record correctly', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); const res = await service.updateRecord({ incident, @@ -236,9 +248,7 @@ describe('Swimlane Service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); await service.updateRecord({ incident, @@ -276,6 +286,24 @@ describe('Swimlane Service', () => { `[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown` ); }); + + test('it should throw if the request is not a JSON', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data, headers: { ['content-type']: 'text/html' } }) + ); + + await expect(service.updateRecord({ incident, incidentId })).rejects.toThrow( + `[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json. Reason: unknown` + ); + }); + + test('it should throw if the required attributes are not there', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(service.updateRecord({ incident, incidentId })).rejects.toThrow( + `[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: Response is missing at least one of the expected fields: id,name,modifiedDate. Reason: unknown` + ); + }); }); describe('createComment', () => { @@ -289,9 +317,7 @@ describe('Swimlane Service', () => { const createdDate = '2021-06-01T17:29:51.092Z'; test('it updates a record correctly', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); const res = await service.createComment({ comment, @@ -306,9 +332,7 @@ describe('Swimlane Service', () => { }); test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => ({ - data, - })); + requestMock.mockImplementation(() => createAxiosResponse({ data })); await service.createComment({ comment, diff --git a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts index f68d22121dbcc..d917d7f5677bb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/swimlane/service.ts @@ -9,7 +9,7 @@ import { Logger } from '@kbn/logging'; import axios from 'axios'; import { ActionsConfigurationUtilities } from '../../actions_config'; -import { getErrorMessage, request } from '../lib/axios_utils'; +import { getErrorMessage, request, throwIfResponseIsNotValid } from '../lib/axios_utils'; import { getBodyForEventAction } from './helpers'; import { CreateCommentParams, @@ -89,6 +89,12 @@ export const createExternalService = ( method: 'post', url: getPostRecordUrl(appId), }); + + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'name', 'createdDate'], + }); + return { id: res.data.id, title: res.data.name, @@ -124,6 +130,11 @@ export const createExternalService = ( url: getPostRecordIdUrl(appId, params.incidentId), }); + throwIfResponseIsNotValid({ + res, + requiredAttributesToBeInTheResponse: ['id', 'name', 'modifiedDate'], + }); + return { id: res.data.id, title: res.data.name, diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts index 9f8e62c77e3a7..6c61d9849c72c 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts @@ -166,7 +166,7 @@ describe('successful migrations', () => { expect(migratedAction).toEqual(action); }); - test('set isLegacy config property for .servicenow', () => { + test('set usesTableApi config property for .servicenow', () => { const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; const action = getMockDataForServiceNow(); const migratedAction = migration716(action, context); @@ -177,13 +177,13 @@ describe('successful migrations', () => { ...action.attributes, config: { apiUrl: 'https://example.com', - isLegacy: true, + usesTableApi: true, }, }, }); }); - test('set isLegacy config property for .servicenow-sir', () => { + test('set usesTableApi config property for .servicenow-sir', () => { const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; const action = getMockDataForServiceNow({ actionTypeId: '.servicenow-sir' }); const migratedAction = migration716(action, context); @@ -194,13 +194,13 @@ describe('successful migrations', () => { ...action.attributes, config: { apiUrl: 'https://example.com', - isLegacy: true, + usesTableApi: true, }, }, }); }); - test('it does not set isLegacy config for other connectors', () => { + test('it does not set usesTableApi config for other connectors', () => { const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0']; const action = getMockData(); const migratedAction = migration716(action, context); diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts index 688839eb89858..2e5b1b5d916fe 100644 --- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts +++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts @@ -68,7 +68,7 @@ export function getActionsMigrations( doc.attributes.actionTypeId === '.servicenow' || doc.attributes.actionTypeId === '.servicenow-sir' || doc.attributes.actionTypeId === '.email', - pipeMigrations(markOldServiceNowITSMConnectorAsLegacy, setServiceConfigIfNotSet) + pipeMigrations(addUsesTableApiToServiceNowConnectors, setServiceConfigIfNotSet) ); const migrationActions800 = createEsoMigration( @@ -197,7 +197,7 @@ const addIsMissingSecretsField = ( }; }; -const markOldServiceNowITSMConnectorAsLegacy = ( +const addUsesTableApiToServiceNowConnectors = ( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc => { if ( @@ -213,7 +213,7 @@ const markOldServiceNowITSMConnectorAsLegacy = ( ...doc.attributes, config: { ...doc.attributes.config, - isLegacy: true, + usesTableApi: true, }, }, }; diff --git a/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts b/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts index 348036252817d..03a96d19b8e8a 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_telemetry.test.ts @@ -52,7 +52,7 @@ Object { `); }); - test('getTotalCountAggregations should return aggregations for throttle, interval and associated actions', async () => { + test('getTotalCountAggregations should return min/max connectors in use', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; mockEsClient.search.mockReturnValue( // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values @@ -65,18 +65,17 @@ Object { 'logs.alert.document.count': 1, 'document.test.': 1, }, - namespaces: { - default: 1, - }, - }, - }, - throttleTime: { value: { min: 0, max: 10, totalCount: 10, totalSum: 20 } }, - intervalTime: { value: { min: 0, max: 2, totalCount: 2, totalSum: 5 } }, - connectorsAgg: { - connectors: { - value: { min: 0, max: 5, totalActionsCount: 10, totalAlertsCount: 2 }, }, }, + max_throttle_time: { value: 60 }, + min_throttle_time: { value: 0 }, + avg_throttle_time: { value: 30 }, + max_interval_time: { value: 10 }, + min_interval_time: { value: 1 }, + avg_interval_time: { value: 4.5 }, + max_actions_count: { value: 4 }, + min_actions_count: { value: 0 }, + avg_actions_count: { value: 2.5 }, }, hits: { hits: [], @@ -92,7 +91,7 @@ Object { Object { "connectors_per_alert": Object { "avg": 2.5, - "max": 5, + "max": 4, "min": 0, }, "count_by_type": Object { @@ -103,13 +102,13 @@ Object { "count_rules_namespaces": 0, "count_total": 4, "schedule_time": Object { - "avg": 2.5, - "max": 2, - "min": 0, + "avg": 4.5, + "max": 10, + "min": 1, }, "throttle_time": Object { - "avg": 2, - "max": 10, + "avg": 30, + "max": 60, "min": 0, }, } diff --git a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts b/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts index ede2ac3613296..7ff9538c1aa26 100644 --- a/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts +++ b/x-pack/plugins/alerting/server/usage/alerts_telemetry.ts @@ -52,219 +52,128 @@ export async function getTotalCountAggregations( | 'count_rules_namespaces' > > { - const throttleTimeMetric = { - scripted_metric: { - init_script: 'state.min = 0; state.max = 0; state.totalSum = 0; state.totalCount = 0;', - map_script: ` - if (doc['alert.throttle'].size() > 0) { - def throttle = doc['alert.throttle'].value; + const { body: results } = await esClient.search({ + index: kibanaInex, + body: { + size: 0, + query: { + bool: { + filter: [{ term: { type: 'alert' } }], + }, + }, + runtime_mappings: { + alert_action_count: { + type: 'long', + script: { + source: ` + def alert = params._source['alert']; + if (alert != null) { + def actions = alert.actions; + if (actions != null) { + emit(actions.length); + } else { + emit(0); + } + }`, + }, + }, + alert_interval: { + type: 'long', + script: { + source: ` + int parsed = 0; + if (doc['alert.schedule.interval'].size() > 0) { + def interval = doc['alert.schedule.interval'].value; - if (throttle.length() > 1) { - // get last char - String timeChar = throttle.substring(throttle.length() - 1); - // remove last char - throttle = throttle.substring(0, throttle.length() - 1); + if (interval.length() > 1) { + // get last char + String timeChar = interval.substring(interval.length() - 1); + // remove last char + interval = interval.substring(0, interval.length() - 1); - if (throttle.chars().allMatch(Character::isDigit)) { - // using of regex is not allowed in painless language - int parsed = Integer.parseInt(throttle); + if (interval.chars().allMatch(Character::isDigit)) { + // using of regex is not allowed in painless language + parsed = Integer.parseInt(interval); - if (timeChar.equals("s")) { - parsed = parsed; - } else if (timeChar.equals("m")) { - parsed = parsed * 60; - } else if (timeChar.equals("h")) { - parsed = parsed * 60 * 60; - } else if (timeChar.equals("d")) { - parsed = parsed * 24 * 60 * 60; - } - if (state.min === 0 || parsed < state.min) { - state.min = parsed; - } - if (parsed > state.max) { - state.max = parsed; + if (timeChar.equals("s")) { + parsed = parsed; + } else if (timeChar.equals("m")) { + parsed = parsed * 60; + } else if (timeChar.equals("h")) { + parsed = parsed * 60 * 60; + } else if (timeChar.equals("d")) { + parsed = parsed * 24 * 60 * 60; + } + emit(parsed); + } } - state.totalSum += parsed; - state.totalCount++; } - } - } - `, - // Combine script is executed per cluster, but we already have a key-value pair per cluster. - // Despite docs that say this is optional, this script can't be blank. - combine_script: 'return state', - // Reduce script is executed across all clusters, so we need to add up all the total from each cluster - // This also needs to account for having no data - reduce_script: ` - double min = 0; - double max = 0; - long totalSum = 0; - long totalCount = 0; - for (Map m : states.toArray()) { - if (m !== null) { - min = min > 0 ? Math.min(min, m.min) : m.min; - max = Math.max(max, m.max); - totalSum += m.totalSum; - totalCount += m.totalCount; - } - } - Map result = new HashMap(); - result.min = min; - result.max = max; - result.totalSum = totalSum; - result.totalCount = totalCount; - return result; - `, - }, - }; - - const intervalTimeMetric = { - scripted_metric: { - init_script: 'state.min = 0; state.max = 0; state.totalSum = 0; state.totalCount = 0;', - map_script: ` - if (doc['alert.schedule.interval'].size() > 0) { - def interval = doc['alert.schedule.interval'].value; + emit(parsed); + `, + }, + }, + alert_throttle: { + type: 'long', + script: { + source: ` + int parsed = 0; + if (doc['alert.throttle'].size() > 0) { + def throttle = doc['alert.throttle'].value; - if (interval.length() > 1) { - // get last char - String timeChar = interval.substring(interval.length() - 1); - // remove last char - interval = interval.substring(0, interval.length() - 1); + if (throttle.length() > 1) { + // get last char + String timeChar = throttle.substring(throttle.length() - 1); + // remove last char + throttle = throttle.substring(0, throttle.length() - 1); - if (interval.chars().allMatch(Character::isDigit)) { - // using of regex is not allowed in painless language - int parsed = Integer.parseInt(interval); + if (throttle.chars().allMatch(Character::isDigit)) { + // using of regex is not allowed in painless language + parsed = Integer.parseInt(throttle); - if (timeChar.equals("s")) { - parsed = parsed; - } else if (timeChar.equals("m")) { - parsed = parsed * 60; - } else if (timeChar.equals("h")) { - parsed = parsed * 60 * 60; - } else if (timeChar.equals("d")) { - parsed = parsed * 24 * 60 * 60; - } - if (state.min === 0 || parsed < state.min) { - state.min = parsed; - } - if (parsed > state.max) { - state.max = parsed; - } - state.totalSum += parsed; - state.totalCount++; + if (timeChar.equals("s")) { + parsed = parsed; + } else if (timeChar.equals("m")) { + parsed = parsed * 60; + } else if (timeChar.equals("h")) { + parsed = parsed * 60 * 60; + } else if (timeChar.equals("d")) { + parsed = parsed * 24 * 60 * 60; + } + emit(parsed); + } } - } - } - `, - // Combine script is executed per cluster, but we already have a key-value pair per cluster. - // Despite docs that say this is optional, this script can't be blank. - combine_script: 'return state', - // Reduce script is executed across all clusters, so we need to add up all the total from each cluster - // This also needs to account for having no data - reduce_script: ` - double min = 0; - double max = 0; - long totalSum = 0; - long totalCount = 0; - for (Map m : states.toArray()) { - if (m !== null) { - min = min > 0 ? Math.min(min, m.min) : m.min; - max = Math.max(max, m.max); - totalSum += m.totalSum; - totalCount += m.totalCount; - } - } - Map result = new HashMap(); - result.min = min; - result.max = max; - result.totalSum = totalSum; - result.totalCount = totalCount; - return result; - `, - }, - }; - - const connectorsMetric = { - scripted_metric: { - init_script: - 'state.currentAlertActions = 0; state.min = 0; state.max = 0; state.totalActionsCount = 0;', - map_script: ` - String refName = doc['alert.actions.actionRef'].value; - if (refName == 'action_0') { - if (state.currentAlertActions !== 0 && state.currentAlertActions < state.min) { - state.min = state.currentAlertActions; - } - if (state.currentAlertActions !== 0 && state.currentAlertActions > state.max) { - state.max = state.currentAlertActions; - } - state.currentAlertActions = 1; - } else { - state.currentAlertActions++; - } - state.totalActionsCount++; - `, - // Combine script is executed per cluster, but we already have a key-value pair per cluster. - // Despite docs that say this is optional, this script can't be blank. - combine_script: 'return state', - // Reduce script is executed across all clusters, so we need to add up all the total from each cluster - // This also needs to account for having no data - reduce_script: ` - double min = 0; - double max = 0; - long totalActionsCount = 0; - long currentAlertActions = 0; - for (Map m : states.toArray()) { - if (m !== null) { - min = min > 0 ? Math.min(min, m.min) : m.min; - max = Math.max(max, m.max); - currentAlertActions += m.currentAlertActions; - totalActionsCount += m.totalActionsCount; - } - } - Map result = new HashMap(); - result.min = min; - result.max = max; - result.currentAlertActions = currentAlertActions; - result.totalActionsCount = totalActionsCount; - return result; - `, - }, - }; - - const { body: results } = await esClient.search({ - index: kibanaInex, - size: 0, - body: { - query: { - bool: { - filter: [{ term: { type: 'alert' } }], + } + emit(parsed); + `, + }, }, }, aggs: { byAlertTypeId: alertTypeMetric, - throttleTime: throttleTimeMetric, - intervalTime: intervalTimeMetric, - connectorsAgg: { - nested: { - path: 'alert.actions', - }, - aggs: { - connectors: connectorsMetric, - }, - }, + max_throttle_time: { max: { field: 'alert_throttle' } }, + min_throttle_time: { min: { field: 'alert_throttle' } }, + avg_throttle_time: { avg: { field: 'alert_throttle' } }, + max_interval_time: { max: { field: 'alert_interval' } }, + min_interval_time: { min: { field: 'alert_interval' } }, + avg_interval_time: { avg: { field: 'alert_interval' } }, + max_actions_count: { max: { field: 'alert_action_count' } }, + min_actions_count: { min: { field: 'alert_action_count' } }, + avg_actions_count: { avg: { field: 'alert_action_count' } }, }, }, }); const aggregations = results.aggregations as { byAlertTypeId: { value: { ruleTypes: Record } }; - throttleTime: { value: { min: number; max: number; totalCount: number; totalSum: number } }; - intervalTime: { value: { min: number; max: number; totalCount: number; totalSum: number } }; - connectorsAgg: { - connectors: { - value: { min: number; max: number; totalActionsCount: number; totalAlertsCount: number }; - }; - }; + max_throttle_time: { value: number }; + min_throttle_time: { value: number }; + avg_throttle_time: { value: number }; + max_interval_time: { value: number }; + min_interval_time: { value: number }; + avg_interval_time: { value: number }; + max_actions_count: { value: number }; + min_actions_count: { value: number }; + avg_actions_count: { value: number }; }; const totalAlertsCount = Object.keys(aggregations.byAlertTypeId.value.ruleTypes).reduce( @@ -285,28 +194,19 @@ export async function getTotalCountAggregations( {} ), throttle_time: { - min: aggregations.throttleTime.value.min, - avg: - aggregations.throttleTime.value.totalCount > 0 - ? aggregations.throttleTime.value.totalSum / aggregations.throttleTime.value.totalCount - : 0, - max: aggregations.throttleTime.value.max, + min: aggregations.min_throttle_time.value, + avg: aggregations.avg_throttle_time.value, + max: aggregations.max_throttle_time.value, }, schedule_time: { - min: aggregations.intervalTime.value.min, - avg: - aggregations.intervalTime.value.totalCount > 0 - ? aggregations.intervalTime.value.totalSum / aggregations.intervalTime.value.totalCount - : 0, - max: aggregations.intervalTime.value.max, + min: aggregations.min_interval_time.value, + avg: aggregations.avg_interval_time.value, + max: aggregations.max_interval_time.value, }, connectors_per_alert: { - min: aggregations.connectorsAgg.connectors.value.min, - avg: - totalAlertsCount > 0 - ? aggregations.connectorsAgg.connectors.value.totalActionsCount / totalAlertsCount - : 0, - max: aggregations.connectorsAgg.connectors.value.max, + min: aggregations.min_actions_count.value, + avg: aggregations.avg_actions_count.value, + max: aggregations.max_actions_count.value, }, count_rules_namespaces: 0, }; diff --git a/x-pack/plugins/apm/dev_docs/linting.md b/x-pack/plugins/apm/dev_docs/linting.md index edf3e813a88e9..3dbd7b5b27484 100644 --- a/x-pack/plugins/apm/dev_docs/linting.md +++ b/x-pack/plugins/apm/dev_docs/linting.md @@ -19,3 +19,12 @@ yarn prettier "./x-pack/plugins/apm/**/*.{tsx,ts,js}" --write ``` node scripts/eslint.js x-pack/plugins/apm ``` + +## Install pre-commit hook (optional) +In case you want to run a couple of checks like linting or check the file casing of the files to commit, we provide a way to install a pre-commit hook. To configure it you just need to run the following: + +`node scripts/register_git_hook` + +After the script completes the pre-commit hook will be created within the file .git/hooks/pre-commit. If you choose to not install it, don’t worry, we still run a quick CI check to provide feedback earliest as we can about the same checks. + +More information about linting can be found in the [Kibana Guide](https://www.elastic.co/guide/en/kibana/current/kibana-linting.html). \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot index 8ca3dc2a94d4e..47c536cd1e2ef 100644 --- a/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot @@ -13,6 +13,24 @@ exports[`Storyshots components/Variables/VarConfig default 1`] = `
+
diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 0fa6335c1e0d9..ed95fdbb022fd 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -20,7 +20,7 @@ import { getDataViewNotFoundMessage } from '../../../../common/i18n_getters'; import { createExtentFilter } from '../../../../common/elasticsearch_util'; import { copyPersistentState } from '../../../reducers/copy_persistent_state'; import { DataRequestAbortError } from '../../util/data_request'; -import { expandToTileBoundaries } from '../../../../common/geo_tile_utils'; +import { expandToTileBoundaries } from '../../util/geo_tile_utils'; import { IVectorSource } from '../vector_source'; import { TimeRange } from '../../../../../../../src/plugins/data/common'; import { diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/__snapshots__/mvt_field_config_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/__snapshots__/mvt_field_config_editor.test.tsx.snap index f6d0129e85abf..b8f7af8b3844e 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/__snapshots__/mvt_field_config_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/__snapshots__/mvt_field_config_editor.test.tsx.snap @@ -5,7 +5,6 @@ exports[`should render error for dupes 1`] = ` { _renderFieldConfig() { return this.state.currentFields.map((mvtFieldConfig: MVTFieldDescriptor, index: number) => { return ( - <> - + + {this._renderFieldNameInput(mvtFieldConfig, index)} {this._renderFieldTypeDropDown(mvtFieldConfig, index)} {this._renderFieldButtonDelete(index)} - + ); }); } diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx index 34a30ae9ec977..387bb9c3ca1ff 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.tsx @@ -82,6 +82,10 @@ export class MVTSingleLayerVectorSource .filter((f) => f !== null) as MVTField[]; } + isMvt() { + return true; + } + async supportsFitToBounds() { return false; } diff --git a/x-pack/plugins/maps/public/classes/sources/source.ts b/x-pack/plugins/maps/public/classes/sources/source.ts index 4c2cffcf8b070..0ecca16fde07b 100644 --- a/x-pack/plugins/maps/public/classes/sources/source.ts +++ b/x-pack/plugins/maps/public/classes/sources/source.ts @@ -29,8 +29,10 @@ export type OnSourceChangeArgs = { }; export type SourceEditorArgs = { - onChange: (...args: OnSourceChangeArgs[]) => void; - currentLayerType?: string; + clearJoins: () => void; + currentLayerType: string; + hasJoins: boolean; + onChange: (...args: OnSourceChangeArgs[]) => Promise; }; export type ImmutableSourceProperty = { @@ -43,6 +45,7 @@ export interface ISource { destroy(): void; getDisplayName(): Promise; getInspectorAdapters(): Adapters | undefined; + getType(): string; isFieldAware(): boolean; isFilterByMapBounds(): boolean; isGeoGridPrecisionAware(): boolean; @@ -101,6 +104,10 @@ export class AbstractSource implements ISource { return this._inspectorAdapters; } + getType(): string { + return this._descriptor.type; + } + async getDisplayName(): Promise { return ''; } diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx index 3c0adf64216e6..7042374296bbe 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx @@ -9,7 +9,7 @@ import type { Query } from 'src/plugins/data/common'; import { FeatureCollection, GeoJsonProperties, Geometry, Position } from 'geojson'; import { Filter, TimeRange } from 'src/plugins/data/public'; import { VECTOR_SHAPE_TYPE } from '../../../../common/constants'; -import { TooltipProperty, ITooltipProperty } from '../../tooltips/tooltip_property'; +import { ITooltipProperty, TooltipProperty } from '../../tooltips/tooltip_property'; import { AbstractSource, ISource } from '../source'; import { IField } from '../../fields/field'; import { @@ -44,6 +44,7 @@ export interface BoundsRequestMeta { } export interface IVectorSource extends ISource { + isMvt(): boolean; getTooltipProperties(properties: GeoJsonProperties): Promise; getBoundsForFilters( layerDataFilters: BoundsRequestMeta, @@ -89,6 +90,10 @@ export class AbstractVectorSource extends AbstractSource implements IVectorSourc return []; } + isMvt() { + return false; + } + createField({ fieldName }: { fieldName: string }): IField { throw new Error('Not implemented'); } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap index 64da5777988d1..be8c9b0750b94 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/__snapshots__/vector_style_editor.test.tsx.snap @@ -384,546 +384,6 @@ exports[`should render 1`] = ` `; -exports[`should render line-style with label properties when ES-source is rendered as mvt 1`] = ` - - - - - - - - - - - - - - - - - - - -`; - -exports[`should render polygon-style without label properties when 3rd party mvt 1`] = ` - - - - - - - - - - - -`; - exports[`should render with no style fields 1`] = ` { class MockField extends AbstractField {} -function createLayerMock( - numFields: number, - supportedShapeTypes: VECTOR_SHAPE_TYPE[], - layerType: LAYER_TYPE = LAYER_TYPE.VECTOR, - isESSource: boolean = false -) { +function createLayerMock(numFields: number, supportedShapeTypes: VECTOR_SHAPE_TYPE[]) { const fields: IField[] = []; for (let i = 0; i < numFields; i++) { fields.push(new MockField({ fieldName: `field${i}`, origin: FIELD_ORIGIN.SOURCE })); @@ -45,17 +39,11 @@ function createLayerMock( getStyleEditorFields: async () => { return fields; }, - getType() { - return layerType; - }, getSource: () => { return { getSupportedShapeTypes: async () => { return supportedShapeTypes; }, - isESSource() { - return isESSource; - }, } as unknown as IVectorSource; }, } as unknown as IVectorLayer; @@ -111,35 +99,3 @@ test('should render with no style fields', async () => { expect(component).toMatchSnapshot(); }); - -test('should render polygon-style without label properties when 3rd party mvt', async () => { - const component = shallow( - - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); -}); - -test('should render line-style with label properties when ES-source is rendered as mvt', async () => { - const component = shallow( - - ); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - - expect(component).toMatchSnapshot(); -}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx index d909f31315e7d..e11a560c8755f 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx @@ -25,7 +25,6 @@ import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_palettes'; import { LABEL_BORDER_SIZES, - LAYER_TYPE, STYLE_TYPE, VECTOR_SHAPE_TYPE, VECTOR_STYLES, @@ -258,18 +257,7 @@ export class VectorStyleEditor extends Component { ); } - _renderLabelProperties(isPoints: boolean) { - if ( - !isPoints && - this.props.layer.getType() === LAYER_TYPE.TILED_VECTOR && - !this.props.layer.getSource().isESSource() - ) { - // This handles and edge-case - // 3rd party lines and polygons from mvt sources cannot be labeled, because they do not have label-centroid geometries inside the tile. - // These label-centroids are only added for ES-sources - return; - } - + _renderLabelProperties() { const hasLabel = this._hasLabel(); const hasLabelBorder = this._hasLabelBorder(); return ( @@ -468,7 +456,7 @@ export class VectorStyleEditor extends Component { /> - {this._renderLabelProperties(true)} + {this._renderLabelProperties()} ); } @@ -482,7 +470,7 @@ export class VectorStyleEditor extends Component { {this._renderLineWidth()} - {this._renderLabelProperties(false)} + {this._renderLabelProperties()} ); } @@ -499,7 +487,7 @@ export class VectorStyleEditor extends Component { {this._renderLineWidth()} - {this._renderLabelProperties(false)} + {this._renderLabelProperties()} ); } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx index 73f8736750656..bff053fc469a0 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx @@ -135,7 +135,7 @@ export class DynamicColorProperty extends DynamicStyleProperty extends IStyleProperty { getFieldMetaOptions(): FieldMetaOptions; getField(): IField | null; getFieldName(): string; + getMbFieldName(): string; getFieldOrigin(): FIELD_ORIGIN | null; getRangeFieldMeta(): RangeFieldMeta | null; getCategoryFieldMeta(): CategoryFieldMeta | null; @@ -63,7 +58,7 @@ export interface IDynamicStyleProperty extends IStyleProperty { getFieldMetaRequest(): Promise; pluckOrdinalStyleMetaFromFeatures(features: Feature[]): RangeFieldMeta | null; pluckCategoricalStyleMetaFromFeatures(features: Feature[]): CategoryFieldMeta | null; - pluckOrdinalStyleMetaFromTileMetaFeatures(features: TileMetaFeature[]): RangeFieldMeta | null; + pluckOrdinalStyleMetaFromTileMetaFeatures(metaFeatures: TileMetaFeature[]): RangeFieldMeta | null; pluckCategoricalStyleMetaFromTileMetaFeatures( features: TileMetaFeature[] ): CategoryFieldMeta | null; @@ -213,6 +208,10 @@ export class DynamicStyleProperty return this._field ? this._field.getName() : ''; } + getMbFieldName() { + return this._field ? this._field.getMbFieldName() : ''; + } + isDynamic() { return true; } @@ -314,54 +313,36 @@ export class DynamicStyleProperty return null; } - const name = this.getFieldName(); + const mbFieldName = this.getMbFieldName(); let min = Infinity; let max = -Infinity; for (let i = 0; i < metaFeatures.length; i++) { - const fieldMeta = metaFeatures[i].properties.fieldMeta; - if (fieldMeta && fieldMeta[name] && fieldMeta[name].range) { - min = Math.min(fieldMeta[name].range?.min as number, min); - max = Math.max(fieldMeta[name].range?.max as number, max); + const fieldMeta = metaFeatures[i].properties; + const minField = `aggregations.${mbFieldName}.min`; + const maxField = `aggregations.${mbFieldName}.max`; + if ( + fieldMeta && + typeof fieldMeta[minField] === 'number' && + typeof fieldMeta[maxField] === 'number' + ) { + min = Math.min(fieldMeta[minField] as number, min); + max = Math.max(fieldMeta[maxField] as number, max); } } - return { - min, - max, - delta: max - min, - }; + + return min === Infinity || max === -Infinity + ? null + : { + min, + max, + delta: max - min, + }; } pluckCategoricalStyleMetaFromTileMetaFeatures( metaFeatures: TileMetaFeature[] ): CategoryFieldMeta | null { - const size = this.getNumberOfCategories(); - if (!this.isCategorical() || size <= 0) { - return null; - } - - const name = this.getFieldName(); - - const counts = new Map(); - for (let i = 0; i < metaFeatures.length; i++) { - const fieldMeta = metaFeatures[i].properties.fieldMeta; - if (fieldMeta && fieldMeta[name] && fieldMeta[name].categories) { - const categoryFieldMeta: CategoryFieldMeta = fieldMeta[name] - .categories as CategoryFieldMeta; - for (let c = 0; c < categoryFieldMeta.categories.length; c++) { - const category: Category = categoryFieldMeta.categories[c]; - // properties object may be sparse, so need to check if the field is effectively present - if (typeof category.key !== undefined) { - if (counts.has(category.key)) { - counts.set(category.key, (counts.get(category.key) as number) + category.count); - } else { - counts.set(category.key, category.count); - } - } - } - } - } - - return trimCategories(counts, size); + return null; } pluckOrdinalStyleMetaFromFeatures(features: Feature[]): RangeFieldMeta | null { @@ -370,9 +351,24 @@ export class DynamicStyleProperty } const name = this.getFieldName(); - return pluckRangeFieldMeta(features, name, (rawValue: unknown) => { - return parseFloat(rawValue as string); - }); + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + const newValue = feature.properties ? parseFloat(feature.properties[name]) : NaN; + if (!isNaN(newValue)) { + min = Math.min(min, newValue); + max = Math.max(max, newValue); + } + } + + return min === Infinity || max === -Infinity + ? null + : { + min, + max, + delta: max - min, + }; } pluckCategoricalStyleMetaFromFeatures(features: Feature[]): CategoryFieldMeta | null { @@ -381,7 +377,32 @@ export class DynamicStyleProperty return null; } - return pluckCategoryFieldMeta(features, this.getFieldName(), size); + const counts = new Map(); + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + const term = feature.properties ? feature.properties[this.getFieldName()] : undefined; + // properties object may be sparse, so need to check if the field is effectively present + if (typeof term !== undefined) { + if (counts.has(term)) { + counts.set(term, counts.get(term) + 1); + } else { + counts.set(term, 1); + } + } + } + + const ordered = []; + for (const [key, value] of counts) { + ordered.push({ key, count: value }); + } + + ordered.sort((a, b) => { + return b.count - a.count; + }); + const truncated = ordered.slice(0, size); + return { + categories: truncated, + } as CategoryFieldMeta; } _pluckOrdinalStyleMetaFromFieldMetaData(styleMetaData: StyleMetaData): RangeFieldMeta | null { @@ -487,7 +508,7 @@ export class DynamicStyleProperty targetName = getComputedFieldName(this.getStyleName(), this._field.getName()); } else { // Non-geojson sources (e.g. 3rd party mvt or ES-source as mvt) - targetName = this._field.getName(); + targetName = this._field.getMbFieldName(); } } return targetName; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index 1e7267b9e1e32..058ee0db08d35 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -16,7 +16,6 @@ import { FIELD_ORIGIN, GEO_JSON_TYPE, KBN_IS_CENTROID_FEATURE, - KBN_VECTOR_SHAPE_TYPE_COUNTS, LAYER_STYLE_TYPE, SOURCE_FORMATTERS_DATA_REQUEST_ID, STYLE_TYPE, @@ -76,7 +75,6 @@ import { IVectorLayer } from '../../layers/vector_layer'; import { IVectorSource } from '../../sources/vector_source'; import { createStyleFieldsHelper, StyleFieldsHelper } from './style_fields_helper'; import { IESAggField } from '../../fields/agg'; -import { VectorShapeTypeCounts } from '../../../../common/get_geometry_counts'; const POINTS = [GEO_JSON_TYPE.POINT, GEO_JSON_TYPE.MULTI_POINT]; const LINES = [GEO_JSON_TYPE.LINE_STRING, GEO_JSON_TYPE.MULTI_LINE_STRING]; @@ -92,9 +90,8 @@ export interface IVectorStyle extends IStyle { previousFields: IField[], mapColors: string[] ): Promise<{ hasChanges: boolean; nextStyleDescriptor?: VectorStyleDescriptor }>; - isTimeAware: () => boolean; - getIcon: () => ReactElement; - getIconFromGeometryTypes: (isLinesOnly: boolean, isPointsOnly: boolean) => ReactElement; + isTimeAware(): boolean; + getIcon(): ReactElement; hasLegendDetails: () => Promise; renderLegendDetails: () => ReactElement; clearFeatureState: (featureCollection: FeatureCollection, mbMap: MbMap, sourceId: string) => void; @@ -492,50 +489,16 @@ export class VectorStyle implements IVectorStyle { } async pluckStyleMetaFromTileMeta(metaFeatures: TileMetaFeature[]): Promise { - const shapeTypeCountMeta: VectorShapeTypeCounts = metaFeatures.reduce( - (accumulator: VectorShapeTypeCounts, tileMeta: TileMetaFeature) => { - if ( - !tileMeta || - !tileMeta.properties || - !tileMeta.properties[KBN_VECTOR_SHAPE_TYPE_COUNTS] - ) { - return accumulator; - } - - accumulator[VECTOR_SHAPE_TYPE.POINT] += - tileMeta.properties[KBN_VECTOR_SHAPE_TYPE_COUNTS][VECTOR_SHAPE_TYPE.POINT]; - accumulator[VECTOR_SHAPE_TYPE.LINE] += - tileMeta.properties[KBN_VECTOR_SHAPE_TYPE_COUNTS][VECTOR_SHAPE_TYPE.LINE]; - accumulator[VECTOR_SHAPE_TYPE.POLYGON] += - tileMeta.properties[KBN_VECTOR_SHAPE_TYPE_COUNTS][VECTOR_SHAPE_TYPE.POLYGON]; - - return accumulator; - }, - { - [VECTOR_SHAPE_TYPE.POLYGON]: 0, - [VECTOR_SHAPE_TYPE.LINE]: 0, - [VECTOR_SHAPE_TYPE.POINT]: 0, - } - ); - - const isLinesOnly = - shapeTypeCountMeta[VECTOR_SHAPE_TYPE.LINE] > 0 && - shapeTypeCountMeta[VECTOR_SHAPE_TYPE.POINT] === 0 && - shapeTypeCountMeta[VECTOR_SHAPE_TYPE.POLYGON] === 0; - const isPointsOnly = - shapeTypeCountMeta[VECTOR_SHAPE_TYPE.LINE] === 0 && - shapeTypeCountMeta[VECTOR_SHAPE_TYPE.POINT] > 0 && - shapeTypeCountMeta[VECTOR_SHAPE_TYPE.POLYGON] === 0; - const isPolygonsOnly = - shapeTypeCountMeta[VECTOR_SHAPE_TYPE.LINE] === 0 && - shapeTypeCountMeta[VECTOR_SHAPE_TYPE.POINT] === 0 && - shapeTypeCountMeta[VECTOR_SHAPE_TYPE.POLYGON] > 0; - + const supportedShapeTypes = await this._source.getSupportedShapeTypes(); const styleMeta: StyleMetaDescriptor = { geometryTypes: { - isPointsOnly, - isLinesOnly, - isPolygonsOnly, + isPointsOnly: + supportedShapeTypes.length === 1 && supportedShapeTypes.includes(VECTOR_SHAPE_TYPE.POINT), + isLinesOnly: + supportedShapeTypes.length === 1 && supportedShapeTypes.includes(VECTOR_SHAPE_TYPE.LINE), + isPolygonsOnly: + supportedShapeTypes.length === 1 && + supportedShapeTypes.includes(VECTOR_SHAPE_TYPE.POLYGON), }, fieldMeta: {}, }; @@ -737,7 +700,7 @@ export class VectorStyle implements IVectorStyle { : (this._iconStyleProperty as StaticIconProperty).getOptions().value; } - getIconFromGeometryTypes(isLinesOnly: boolean, isPointsOnly: boolean) { + _getIconFromGeometryTypes(isLinesOnly: boolean, isPointsOnly: boolean) { let strokeColor; if (isLinesOnly) { strokeColor = extractColorFromStyleProperty( @@ -771,7 +734,7 @@ export class VectorStyle implements IVectorStyle { getIcon() { const isLinesOnly = this._getIsLinesOnly(); const isPointsOnly = this._getIsPointsOnly(); - return this.getIconFromGeometryTypes(isLinesOnly, isPointsOnly); + return this._getIconFromGeometryTypes(isLinesOnly, isPointsOnly); } _getLegendDetailStyleProperties = () => { diff --git a/x-pack/plugins/maps/common/geo_tile_utils.test.js b/x-pack/plugins/maps/public/classes/util/geo_tile_utils.test.ts similarity index 100% rename from x-pack/plugins/maps/common/geo_tile_utils.test.js rename to x-pack/plugins/maps/public/classes/util/geo_tile_utils.test.ts diff --git a/x-pack/plugins/maps/common/geo_tile_utils.ts b/x-pack/plugins/maps/public/classes/util/geo_tile_utils.ts similarity index 95% rename from x-pack/plugins/maps/common/geo_tile_utils.ts rename to x-pack/plugins/maps/public/classes/util/geo_tile_utils.ts index 1a8ac3cbe17ae..6e82d3b509565 100644 --- a/x-pack/plugins/maps/common/geo_tile_utils.ts +++ b/x-pack/plugins/maps/public/classes/util/geo_tile_utils.ts @@ -6,9 +6,9 @@ */ import _ from 'lodash'; -import { DECIMAL_DEGREES_PRECISION } from './constants'; -import { clampToLatBounds } from './elasticsearch_util'; -import { MapExtent } from './descriptor_types'; +import { DECIMAL_DEGREES_PRECISION } from '../../../common/constants'; +import { clampToLatBounds } from '../../../common/elasticsearch_util'; +import { MapExtent } from '../../../common/descriptor_types'; const ZOOM_TILE_KEY_INDEX = 0; const X_TILE_KEY_INDEX = 1; diff --git a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts index 68efd416718fd..544b2697cab43 100644 --- a/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts +++ b/x-pack/plugins/maps/public/classes/util/mb_filter_expressions.ts @@ -9,7 +9,6 @@ import { GEO_JSON_TYPE, FEATURE_VISIBLE_PROPERTY_NAME, KBN_IS_CENTROID_FEATURE, - KBN_METADATA_FEATURE, } from '../../../common/constants'; import { Timeslice } from '../../../common/descriptor_types'; @@ -19,7 +18,6 @@ export interface TimesliceMaskConfig { timeslice: Timeslice; } -export const EXCLUDE_TOO_MANY_FEATURES_BOX = ['!=', ['get', KBN_METADATA_FEATURE], true]; export const EXCLUDE_CENTROID_FEATURES = ['!=', ['get', KBN_IS_CENTROID_FEATURE], true]; function getFilterExpression( @@ -56,7 +54,6 @@ export function getFillFilterExpression( ): unknown[] { return getFilterExpression( [ - EXCLUDE_TOO_MANY_FEATURES_BOX, EXCLUDE_CENTROID_FEATURES, [ 'any', @@ -75,7 +72,6 @@ export function getLineFilterExpression( ): unknown[] { return getFilterExpression( [ - EXCLUDE_TOO_MANY_FEATURES_BOX, EXCLUDE_CENTROID_FEATURES, [ 'any', @@ -96,7 +92,6 @@ export function getPointFilterExpression( ): unknown[] { return getFilterExpression( [ - EXCLUDE_TOO_MANY_FEATURES_BOX, EXCLUDE_CENTROID_FEATURES, [ 'any', @@ -109,13 +104,17 @@ export function getPointFilterExpression( ); } -export function getCentroidFilterExpression( +export function getLabelFilterExpression( hasJoins: boolean, + isSourceGeoJson: boolean, timesliceMaskConfig?: TimesliceMaskConfig ): unknown[] { - return getFilterExpression( - [EXCLUDE_TOO_MANY_FEATURES_BOX, ['==', ['get', KBN_IS_CENTROID_FEATURE], true]], - hasJoins, - timesliceMaskConfig - ); + const filters: unknown[] = []; + + // centroids added for geojson sources only + if (isSourceGeoJson) { + filters.push(['==', ['get', KBN_IS_CENTROID_FEATURE], true]); + } + + return getFilterExpression(filters, hasJoins, timesliceMaskConfig); } diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/__snapshots__/edit_layer_panel.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/__snapshots__/edit_layer_panel.test.tsx.snap index 5fb1cc6f72585..03e8b4a90c0ca 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/__snapshots__/edit_layer_panel.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/__snapshots__/edit_layer_panel.test.tsx.snap @@ -55,6 +55,8 @@ exports[`EditLayerPanel is rendered 1`] = ` { import React from 'react'; import { shallow } from 'enzyme'; +import { LAYER_TYPE } from '../../../common/constants'; import { ILayer } from '../../classes/layers/layer'; import { EditLayerPanel } from './edit_layer_panel'; @@ -55,6 +56,9 @@ const mockLayer = { getId: () => { return '1'; }, + getType: () => { + return LAYER_TYPE.VECTOR; + }, getDisplayName: () => { return 'layer 1'; }, @@ -79,6 +83,9 @@ const mockLayer = { hasErrors: () => { return false; }, + hasJoins: () => { + return false; + }, supportsFitToBounds: () => { return true; }, @@ -87,7 +94,8 @@ const mockLayer = { const defaultProps = { selectedLayer: mockLayer, fitToBounds: () => {}, - updateSourceProp: () => {}, + updateSourceProps: async () => {}, + clearJoins: () => {}, }; describe('EditLayerPanel', () => { diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/edit_layer_panel.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/edit_layer_panel.tsx index 424c4b8e16bec..7ba2f74ddc7d9 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/edit_layer_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/edit_layer_panel.tsx @@ -30,7 +30,6 @@ import { StyleSettings } from './style_settings'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { LAYER_TYPE } from '../../../common/constants'; import { getData, getCore } from '../../kibana_services'; import { ILayer } from '../../classes/layers/layer'; import { isVectorLayer, IVectorLayer } from '../../classes/layers/vector_layer'; @@ -40,13 +39,9 @@ import { IField } from '../../classes/fields/field'; const localStorage = new Storage(window.localStorage); export interface Props { + clearJoins: (layer: ILayer) => void; selectedLayer?: ILayer; - updateSourceProp: ( - layerId: string, - propName: string, - value: unknown, - newLayerType?: LAYER_TYPE - ) => void; + updateSourceProps: (layerId: string, sourcePropChanges: OnSourceChangeArgs[]) => Promise; } interface State { @@ -141,9 +136,12 @@ export class EditLayerPanel extends Component { } _onSourceChange = (...args: OnSourceChangeArgs[]) => { - for (let i = 0; i < args.length; i++) { - const { propName, value, newLayerType } = args[i]; - this.props.updateSourceProp(this.props.selectedLayer!.getId(), propName, value, newLayerType); + return this.props.updateSourceProps(this.props.selectedLayer!.getId(), args); + }; + + _clearJoins = () => { + if (this.props.selectedLayer) { + this.props.clearJoins(this.props.selectedLayer); } }; @@ -279,6 +277,11 @@ export class EditLayerPanel extends Component { /> {this.props.selectedLayer.renderSourceSettingsEditor({ + clearJoins: this._clearJoins, + currentLayerType: this.props.selectedLayer.getType(), + hasJoins: isVectorLayer(this.props.selectedLayer) + ? (this.props.selectedLayer as IVectorLayer).hasJoins() + : false, onChange: this._onSourceChange, })} diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/index.ts b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/index.ts index 84caa45741a62..5f9a920d38494 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/index.ts +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/index.ts @@ -9,11 +9,12 @@ import { AnyAction } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; import { connect } from 'react-redux'; import { EditLayerPanel } from './edit_layer_panel'; -import { LAYER_TYPE } from '../../../common/constants'; import { getSelectedLayer } from '../../selectors/map_selectors'; -import { updateSourceProp } from '../../actions'; +import { setJoinsForLayer, updateSourceProps } from '../../actions'; import { MapStoreState } from '../../reducers/store'; +import { ILayer } from '../../classes/layers/layer'; import { isVectorLayer, IVectorLayer } from '../../classes/layers/vector_layer'; +import { OnSourceChangeArgs } from '../../classes/sources/source'; function mapStateToProps(state: MapStoreState) { const selectedLayer = getSelectedLayer(state); @@ -31,8 +32,11 @@ function mapStateToProps(state: MapStoreState) { function mapDispatchToProps(dispatch: ThunkDispatch) { return { - updateSourceProp: (id: string, propName: string, value: unknown, newLayerType?: LAYER_TYPE) => - dispatch(updateSourceProp(id, propName, value, newLayerType)), + clearJoins: (layer: ILayer) => { + dispatch(setJoinsForLayer(layer, [])); + }, + updateSourceProps: async (id: string, sourcePropChanges: OnSourceChangeArgs[]) => + await dispatch(updateSourceProps(id, sourcePropChanges)), }; } diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx index e99ec6a688092..24cdd9b7dc813 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/join_editor.tsx @@ -90,9 +90,7 @@ export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDispla ) : ( {renderJoins()} - - { ] as [MbPoint, MbPoint]; const selectedFeatures = this.props.mbMap.queryRenderedFeatures(mbBbox, { layers: mbEditLayerIds, - filter: ['all', EXCLUDE_TOO_MANY_FEATURES_BOX, EXCLUDE_CENTROID_FEATURES], + filter: ['all', EXCLUDE_CENTROID_FEATURES], }); if (!selectedFeatures.length) { return; diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx index 053e410b8c712..93dfebecd1c34 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx @@ -33,7 +33,6 @@ import { } from '../../../common/descriptor_types'; import { DECIMAL_DEGREES_PRECISION, - KBN_TOO_MANY_FEATURES_IMAGE_ID, LAYER_TYPE, RawValue, ZOOM_PRECISION, @@ -209,14 +208,6 @@ export class MbMap extends Component { }, }); - const tooManyFeaturesImageSrc = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA7DgAAOw4BzLahgwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAARLSURBVHic7ZnPbxRVAMe/7735sWO3293ZlUItJsivCxEE0oTYRgu1FqTQoFSwKTYx8SAH/wHjj4vRozGGi56sMcW2UfqTEuOhppE0KJc2GIuKQFDY7qzdtrudX88D3YTUdFuQN8+k87ltZt7uZz958/bNLAGwBWsYKltANmEA2QKyCQPIFpBNGEC2gGzCALIFZBMGkC0gmzCAbAHZhAFkC8gmDCBbQDZhANkCslnzARQZH6oDpNs0D5UDSUIInePcOpPLfdfnODNBuwQWIAWwNOABwHZN0x8npE6hNLJ4DPWRyFSf40wE5VOEQPBjcR0g3YlE4ybGmtK+/1NzJtOZA/xSYwZMs3nG962T2ez3It2AANaA/kSidYuivOQBs5WM1fUnk6f0u+GXJUqIuUtVXx00zRbRfkIDfBqL7a1WlIYbjvNtTTr99jXXHVpH6dMjK0R4cXq6c9rzxjcx9sKX8XitSEdhAToMI7VP10/97fsTh7PZrgWAN1lW72KE2vOm2b5chDTgtWQyn93x/bEEIetEOQIC14CxVOr1CkKefH929t0v8vn0vcdGEoljGxXl4C3PGz2YyXy+AHARDqtByAxoUdWKBKV70r4/vvTLA0CjZfX+5nkDGxirKzUTgkBIgNaysh3gnF627R+XO+dQJvP1ddcdrmSsbtA020pF+CAW21qrqmUiXIUEqGRsIwD0FQq/lzqv0bJ6rrvucBVjzwyb5ivLRTiiaW+8VV7eIEBVTAANiIIQd9RxZlc6t9Gyem647vn1jD07ZJonl4sQASoevqmgABzwwHnJzc69PGdZ3X+47sgGxuqHTPPE0ggeVtg5/QeEBMhxPg1Aa1DV2GrHPG9ZXy1G2D+wNALn9jyQEeHKAJgP+033Kgrdqij7AFwZtu3bqx3XWShMHtV1o1pRGo4YxiNd+fyEB2DKdX/4aG5u0hbwcylkBryTy/3scT6zW9Nq7ndso2Wdvea6Q1WUHuiPx1/WAXLBcWZXun94UMRcAoD/p+ddTFK6u8MwUvc7vsmyem+67oVqVT0wkEgcF+FYRNhW+L25uX6f84XThtHxIBudE5bVY/t++jFVrU/dvVSFICzAqG3PX/S8rihj2/61qK1AOUB7ksl2jdLUL7Z9rvgcQQRCFsEi5wqFmw26XnhCUQ63GcZmCly95Lrzpca0G0byk3j8tEnpU1c975tmyxoU5QcE8EAEAM5WVOzfoarHAeC2749dcpzxMwsLv07Ztg0AOzVNf03Ttu/S9T2PMlbjc25fdpyutmx2TLRbIAEA4M1otKo1EjmaoHQn4ZwBgA/kAVAK6MXXdzxv/ONcrq/HcbJBeAUWoEizqsaORaPbKglZrxMSZZyrM76f/ovzWx/m85PFWREUgQf4v7Hm/xcIA8gWkE0YQLaAbMIAsgVkEwaQLSCbMIBsAdmEAWQLyCYMIFtANmEA2QKyCQPIFpDNmg/wD3OFdEybUvJjAAAAAElFTkSuQmCC'; - const tooManyFeaturesImage = new Image(); - tooManyFeaturesImage.onload = () => { - mbMap.addImage(KBN_TOO_MANY_FEATURES_IMAGE_ID, tooManyFeaturesImage); - }; - tooManyFeaturesImage.src = tooManyFeaturesImageSrc; - let emptyImage: HTMLImageElement; mbMap.on('styleimagemissing', (e: unknown) => { if (emptyImage) { diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.tsx index 7e79113d6b242..04b1d2205644f 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.test.tsx @@ -36,6 +36,19 @@ const mockLayer = { canShowTooltip: () => { return true; }, + getMbTooltipLayerIds: () => { + return ['foo', 'bar']; + }, + getSource: () => { + return { + isMvt: () => { + return false; + }, + isESSource: () => { + return false; + }, + }; + }, getFeatureById: () => { return { geometry: { diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.tsx index c2ad75d9cb335..c2b89e64a449b 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/tooltip_control/tooltip_control.tsx @@ -19,12 +19,7 @@ import uuid from 'uuid/v4'; import { Geometry } from 'geojson'; import { Filter } from 'src/plugins/data/public'; import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; -import { - FEATURE_ID_PROPERTY_NAME, - GEO_JSON_TYPE, - LON_INDEX, - RawValue, -} from '../../../../common/constants'; +import { GEO_JSON_TYPE, LON_INDEX, RawValue } from '../../../../common/constants'; import { GEOMETRY_FILTER_ACTION, TooltipFeature, @@ -33,9 +28,8 @@ import { } from '../../../../common/descriptor_types'; import { TooltipPopover } from './tooltip_popover'; import { FeatureGeometryFilterForm } from './features_tooltip'; -import { EXCLUDE_TOO_MANY_FEATURES_BOX } from '../../../classes/util/mb_filter_expressions'; import { ILayer } from '../../../classes/layers/layer'; -import { IVectorLayer, isVectorLayer } from '../../../classes/layers/vector_layer'; +import { IVectorLayer, isVectorLayer, getFeatureId } from '../../../classes/layers/vector_layer'; import { RenderToolTipContent } from '../../../classes/tooltips/tooltip_property'; function justifyAnchorLocation( @@ -132,7 +126,13 @@ export class TooltipControl extends Component { }) as IVectorLayer; } - _loadPreIndexedShape = async ({ layerId, featureId }: { layerId: string; featureId: string }) => { + _loadPreIndexedShape = async ({ + layerId, + featureId, + }: { + layerId: string; + featureId?: string | number; + }) => { const tooltipLayer = this._findLayerById(layerId); if (!tooltipLayer || typeof featureId === 'undefined') { return null; @@ -152,7 +152,7 @@ export class TooltipControl extends Component { tooltipId, }: { layerId: string; - featureId: string; + featureId?: string | number; tooltipId: string; }): TooltipFeatureAction[] { const actions = []; @@ -203,7 +203,8 @@ export class TooltipControl extends Component { if (!layer) { break; } - const featureId = mbFeature.properties?.[FEATURE_ID_PROPERTY_NAME]; + + const featureId = getFeatureId(mbFeature, layer.getSource()); const layerId = layer.getId(); let match = false; for (let j = 0; j < uniqueFeatures.length; j++) { @@ -284,9 +285,10 @@ export class TooltipControl extends Component { } const targetMbFeature = mbFeatures[0]; - if (this.props.openTooltips[0] && this.props.openTooltips[0].features.length) { + const layer = this._getLayerByMbLayerId(targetMbFeature.layer.id); + if (layer && this.props.openTooltips[0] && this.props.openTooltips[0].features.length) { const firstFeature = this.props.openTooltips[0].features[0]; - if (targetMbFeature.properties?.[FEATURE_ID_PROPERTY_NAME] === firstFeature.id) { + if (getFeatureId(targetMbFeature, layer.getSource()) === firstFeature.id) { // ignore hover events when hover tooltip is all ready opened for feature return; } @@ -312,7 +314,7 @@ export class TooltipControl extends Component { (accumulator: string[], layer: ILayer) => { // tooltips are only supported for vector layers, filter out all other layer types return layer.isVisible() && isVectorLayer(layer) - ? accumulator.concat(layer.getMbLayerIds()) + ? accumulator.concat((layer as IVectorLayer).getMbTooltipLayerIds()) : accumulator; }, [] @@ -347,7 +349,6 @@ export class TooltipControl extends Component { ] as [MbPoint, MbPoint]; return this.props.mbMap.queryRenderedFeatures(mbBbox, { layers: mbLayerIds, - filter: EXCLUDE_TOO_MANY_FEATURES_BOX, }); } diff --git a/x-pack/plugins/maps/server/kibana_server_services.ts b/x-pack/plugins/maps/server/kibana_server_services.ts index e3c612f415c4d..f5bd4dad085d8 100644 --- a/x-pack/plugins/maps/server/kibana_server_services.ts +++ b/x-pack/plugins/maps/server/kibana_server_services.ts @@ -20,14 +20,17 @@ export const setInternalRepository = ( }; export const getInternalRepository = () => internalRepository; +let esClient: ElasticsearchClient; let indexPatternsService: IndexPatternsCommonService; export const setIndexPatternsService = async ( indexPatternsServiceFactory: IndexPatternsServiceStart['indexPatternsServiceFactory'], elasticsearchClient: ElasticsearchClient ) => { + esClient = elasticsearchClient; indexPatternsService = await indexPatternsServiceFactory( new SavedObjectsClient(getInternalRepository()), elasticsearchClient ); }; export const getIndexPatternsService = () => indexPatternsService; +export const getESClient = () => esClient; diff --git a/x-pack/plugins/maps/server/mvt/get_grid_tile.ts b/x-pack/plugins/maps/server/mvt/get_grid_tile.ts new file mode 100644 index 0000000000000..cfc894d512450 --- /dev/null +++ b/x-pack/plugins/maps/server/mvt/get_grid_tile.ts @@ -0,0 +1,64 @@ +/* + * 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 { Logger } from 'src/core/server'; +import type { DataRequestHandlerContext } from 'src/plugins/data/server'; +import { RENDER_AS } from '../../common/constants'; + +function isAbortError(error: Error) { + return error.message === 'Request aborted' || error.message === 'Aborted'; +} + +export async function getEsGridTile({ + logger, + context, + index, + geometryFieldName, + x, + y, + z, + requestBody = {}, + requestType = RENDER_AS.POINT, +}: { + x: number; + y: number; + z: number; + geometryFieldName: string; + index: string; + context: DataRequestHandlerContext; + logger: Logger; + requestBody: any; + requestType: RENDER_AS.GRID | RENDER_AS.POINT; +}): Promise { + try { + const path = `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`; + const body = { + size: 0, // no hits + grid_precision: 7, + exact_bounds: false, + extent: 4096, // full resolution, + query: requestBody.query, + grid_type: requestType === RENDER_AS.GRID ? 'grid' : 'centroid', + aggs: requestBody.aggs, + fields: requestBody.fields, + runtime_mappings: requestBody.runtime_mappings, + }; + const tile = await context.core.elasticsearch.client.asCurrentUser.transport.request({ + method: 'GET', + path, + body, + }); + return tile.body as unknown as Buffer; + } catch (e) { + if (!isAbortError(e)) { + // These are often circuit breaking exceptions + // Should return a tile with some error message + logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`); + } + return null; + } +} diff --git a/x-pack/plugins/maps/server/mvt/get_tile.ts b/x-pack/plugins/maps/server/mvt/get_tile.ts index 11e1af5a7d368..0864b373af3f8 100644 --- a/x-pack/plugins/maps/server/mvt/get_tile.ts +++ b/x-pack/plugins/maps/server/mvt/get_tile.ts @@ -5,53 +5,15 @@ * 2.0. */ -// @ts-expect-error -import geojsonvt from 'geojson-vt'; -// @ts-expect-error -import vtpbf from 'vt-pbf'; +import _ from 'lodash'; import { Logger } from 'src/core/server'; import type { DataRequestHandlerContext } from 'src/plugins/data/server'; -import { Feature, FeatureCollection, Polygon } from 'geojson'; -import { countVectorShapeTypes } from '../../common/get_geometry_counts'; -import { - COUNT_PROP_NAME, - ES_GEO_FIELD_TYPE, - FEATURE_ID_PROPERTY_NAME, - GEOTILE_GRID_AGG_NAME, - KBN_FEATURE_COUNT, - KBN_IS_TILE_COMPLETE, - KBN_METADATA_FEATURE, - KBN_VECTOR_SHAPE_TYPE_COUNTS, - MAX_ZOOM, - MVT_SOURCE_LAYER_NAME, - RENDER_AS, - SUPER_FINE_ZOOM_DELTA, - VECTOR_SHAPE_TYPE, -} from '../../common/constants'; - -import { - createExtentFilter, - convertRegularRespToGeoJson, - hitsToGeoJson, - isTotalHitsGreaterThan, - formatEnvelopeAsPolygon, - TotalHits, -} from '../../common/elasticsearch_util'; -import { flattenHit } from './util'; -import { ESBounds, tileToESBbox } from '../../common/geo_tile_utils'; -import { getCentroidFeatures } from '../../common/get_centroid_features'; -import { pluckRangeFieldMeta } from '../../common/pluck_range_field_meta'; -import { FieldMeta, TileMetaFeature } from '../../common/descriptor_types'; -import { pluckCategoryFieldMeta } from '../../common/pluck_category_field_meta'; - -// heuristic. largest color-palette has 30 colors. 1 color is used for 'other'. -const TERM_COUNT = 30 - 1; function isAbortError(error: Error) { return error.message === 'Request aborted' || error.message === 'Aborted'; } -export async function getGridTile({ +export async function getEsTile({ logger, context, index, @@ -60,9 +22,6 @@ export async function getGridTile({ y, z, requestBody = {}, - requestType = RENDER_AS.POINT, - searchSessionId, - abortSignal, }: { x: number; y: number; @@ -72,388 +31,32 @@ export async function getGridTile({ context: DataRequestHandlerContext; logger: Logger; requestBody: any; - requestType: RENDER_AS.GRID | RENDER_AS.POINT; - geoFieldType: ES_GEO_FIELD_TYPE; - searchSessionId?: string; - abortSignal: AbortSignal; }): Promise { try { - const tileBounds: ESBounds = tileToESBbox(x, y, z); - requestBody.query.bool.filter.push(getTileSpatialFilter(geometryFieldName, tileBounds)); - requestBody.aggs[GEOTILE_GRID_AGG_NAME].geotile_grid.precision = Math.min( - z + SUPER_FINE_ZOOM_DELTA, - MAX_ZOOM - ); - requestBody.aggs[GEOTILE_GRID_AGG_NAME].geotile_grid.bounds = tileBounds; - requestBody.track_total_hits = false; - - const response = await context - .search!.search( - { - params: { - index, - body: requestBody, - }, - }, - { - sessionId: searchSessionId, - legacyHitsTotal: false, - abortSignal, - } - ) - .toPromise(); - const features: Feature[] = convertRegularRespToGeoJson(response.rawResponse, requestType); - - if (features.length) { - const bounds = formatEnvelopeAsPolygon({ - maxLat: tileBounds.top_left.lat, - minLat: tileBounds.bottom_right.lat, - maxLon: tileBounds.bottom_right.lon, - minLon: tileBounds.top_left.lon, - }); - - const fieldNames = new Set(); - features.forEach((feature) => { - for (const key in feature.properties) { - if (feature.properties.hasOwnProperty(key) && key !== 'key' && key !== 'gridCentroid') { - fieldNames.add(key); - } - } - }); - - const fieldMeta: FieldMeta = {}; - fieldNames.forEach((fieldName: string) => { - const rangeMeta = pluckRangeFieldMeta(features, fieldName, (rawValue: unknown) => { - if (fieldName === COUNT_PROP_NAME) { - return parseFloat(rawValue as string); - } else if (typeof rawValue === 'number') { - return rawValue; - } else if (rawValue) { - return parseFloat((rawValue as { value: string }).value); - } else { - return NaN; - } - }); - - const categoryMeta = pluckCategoryFieldMeta(features, fieldName, TERM_COUNT); - - if (!fieldMeta[fieldName]) { - fieldMeta[fieldName] = {}; - } - - if (rangeMeta) { - fieldMeta[fieldName].range = rangeMeta; - } - - if (categoryMeta) { - fieldMeta[fieldName].categories = categoryMeta; - } - }); - - const metaDataFeature: TileMetaFeature = { - type: 'Feature', - properties: { - [KBN_METADATA_FEATURE]: true, - [KBN_FEATURE_COUNT]: features.length, - [KBN_IS_TILE_COMPLETE]: true, - [KBN_VECTOR_SHAPE_TYPE_COUNTS]: - requestType === RENDER_AS.GRID - ? { - [VECTOR_SHAPE_TYPE.POINT]: 0, - [VECTOR_SHAPE_TYPE.LINE]: 0, - [VECTOR_SHAPE_TYPE.POLYGON]: features.length, - } - : { - [VECTOR_SHAPE_TYPE.POINT]: features.length, - [VECTOR_SHAPE_TYPE.LINE]: 0, - [VECTOR_SHAPE_TYPE.POLYGON]: 0, - }, - fieldMeta, - }, - geometry: bounds, - }; - - features.push(metaDataFeature); - } - - const featureCollection: FeatureCollection = { - features, - type: 'FeatureCollection', + const path = `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`; + let fields = _.uniq(requestBody.docvalue_fields.concat(requestBody.stored_fields)); + fields = fields.filter((f) => f !== geometryFieldName); + const body = { + grid_precision: 0, // no aggs + exact_bounds: true, + extent: 4096, // full resolution, + query: requestBody.query, + fields, + runtime_mappings: requestBody.runtime_mappings, + track_total_hits: requestBody.size + 1, }; - - return createMvtTile(featureCollection, z, x, y); + const tile = await context.core.elasticsearch.client.asCurrentUser.transport.request({ + method: 'GET', + path, + body, + }); + return tile.body as unknown as Buffer; } catch (e) { if (!isAbortError(e)) { // These are often circuit breaking exceptions // Should return a tile with some error message - logger.warn(`Cannot generate grid-tile for ${z}/${x}/${y}: ${e.message}`); + logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`); } return null; } } - -export async function getTile({ - logger, - context, - index, - geometryFieldName, - x, - y, - z, - requestBody = {}, - geoFieldType, - searchSessionId, - abortSignal, -}: { - x: number; - y: number; - z: number; - geometryFieldName: string; - index: string; - context: DataRequestHandlerContext; - logger: Logger; - requestBody: any; - geoFieldType: ES_GEO_FIELD_TYPE; - searchSessionId?: string; - abortSignal: AbortSignal; -}): Promise { - let features: Feature[]; - try { - requestBody.query.bool.filter.push( - getTileSpatialFilter(geometryFieldName, tileToESBbox(x, y, z)) - ); - - const searchOptions = { - sessionId: searchSessionId, - legacyHitsTotal: false, - abortSignal, - }; - - const countResponse = await context - .search!.search( - { - params: { - index, - body: { - size: 0, - query: requestBody.query, - track_total_hits: requestBody.size + 1, - }, - }, - }, - searchOptions - ) - .toPromise(); - - if ( - isTotalHitsGreaterThan( - countResponse.rawResponse.hits.total as unknown as TotalHits, - requestBody.size - ) - ) { - // Generate "too many features"-bounds - const bboxResponse = await context - .search!.search( - { - params: { - index, - body: { - size: 0, - query: requestBody.query, - aggs: { - data_bounds: { - geo_bounds: { - field: geometryFieldName, - }, - }, - }, - track_total_hits: false, - }, - }, - }, - searchOptions - ) - .toPromise(); - - const metaDataFeature: TileMetaFeature = { - type: 'Feature', - properties: { - [KBN_METADATA_FEATURE]: true, - [KBN_IS_TILE_COMPLETE]: false, - [KBN_FEATURE_COUNT]: 0, - [KBN_VECTOR_SHAPE_TYPE_COUNTS]: { - [VECTOR_SHAPE_TYPE.POINT]: 0, - [VECTOR_SHAPE_TYPE.LINE]: 0, - [VECTOR_SHAPE_TYPE.POLYGON]: 0, - }, - }, - geometry: esBboxToGeoJsonPolygon( - // @ts-expect-error @elastic/elasticsearch no way to declare aggregations for search response - bboxResponse.rawResponse.aggregations.data_bounds.bounds, - tileToESBbox(x, y, z) - ), - }; - features = [metaDataFeature]; - } else { - const documentsResponse = await context - .search!.search( - { - params: { - index, - body: { - ...requestBody, - track_total_hits: false, - }, - }, - }, - searchOptions - ) - .toPromise(); - - const featureCollection = hitsToGeoJson( - // @ts-expect-error hitsToGeoJson should be refactored to accept estypes.SearchHit - documentsResponse.rawResponse.hits.hits, - (hit: Record) => { - return flattenHit(geometryFieldName, hit); - }, - geometryFieldName, - geoFieldType, - [] - ); - - features = featureCollection.features; - - // Correct system-fields. - for (let i = 0; i < features.length; i++) { - const props = features[i].properties; - if (props !== null) { - props[FEATURE_ID_PROPERTY_NAME] = features[i].id; - } - } - - const counts = countVectorShapeTypes(features); - - const fieldNames = new Set(); - features.forEach((feature) => { - for (const key in feature.properties) { - if ( - feature.properties.hasOwnProperty(key) && - key !== '_index' && - key !== '_id' && - key !== FEATURE_ID_PROPERTY_NAME - ) { - fieldNames.add(key); - } - } - }); - - const fieldMeta: FieldMeta = {}; - fieldNames.forEach((fieldName: string) => { - const rangeMeta = pluckRangeFieldMeta(features, fieldName, (rawValue: unknown) => { - return typeof rawValue === 'number' ? rawValue : NaN; - }); - const categoryMeta = pluckCategoryFieldMeta(features, fieldName, TERM_COUNT); - - if (!fieldMeta[fieldName]) { - fieldMeta[fieldName] = {}; - } - - if (rangeMeta) { - fieldMeta[fieldName].range = rangeMeta; - } - - if (categoryMeta) { - fieldMeta[fieldName].categories = categoryMeta; - } - }); - - const metadataFeature: TileMetaFeature = { - type: 'Feature', - properties: { - [KBN_METADATA_FEATURE]: true, - [KBN_IS_TILE_COMPLETE]: true, - [KBN_VECTOR_SHAPE_TYPE_COUNTS]: counts, - [KBN_FEATURE_COUNT]: features.length, - fieldMeta, - }, - geometry: esBboxToGeoJsonPolygon(tileToESBbox(x, y, z), tileToESBbox(x, y, z)), - }; - - features.push(metadataFeature); - } - - const featureCollection: FeatureCollection = { - features, - type: 'FeatureCollection', - }; - - return createMvtTile(featureCollection, z, x, y); - } catch (e) { - if (!isAbortError(e)) { - logger.warn(`Cannot generate tile for ${z}/${x}/${y}: ${e.message}`); - } - return null; - } -} - -function getTileSpatialFilter(geometryFieldName: string, tileBounds: ESBounds): unknown { - const tileExtent = { - minLon: tileBounds.top_left.lon, - minLat: tileBounds.bottom_right.lat, - maxLon: tileBounds.bottom_right.lon, - maxLat: tileBounds.top_left.lat, - }; - const tileExtentFilter = createExtentFilter(tileExtent, [geometryFieldName]); - return tileExtentFilter.query; -} - -function esBboxToGeoJsonPolygon(esBounds: ESBounds, tileBounds: ESBounds): Polygon { - // Intersecting geo_shapes may push bounding box outside of tile so need to clamp to tile bounds. - let minLon = Math.max(esBounds.top_left.lon, tileBounds.top_left.lon); - const maxLon = Math.min(esBounds.bottom_right.lon, tileBounds.bottom_right.lon); - minLon = minLon > maxLon ? minLon - 360 : minLon; // fixes an ES bbox to straddle dateline - const minLat = Math.max(esBounds.bottom_right.lat, tileBounds.bottom_right.lat); - const maxLat = Math.min(esBounds.top_left.lat, tileBounds.top_left.lat); - - return { - type: 'Polygon', - coordinates: [ - [ - [minLon, minLat], - [minLon, maxLat], - [maxLon, maxLat], - [maxLon, minLat], - [minLon, minLat], - ], - ], - }; -} - -function createMvtTile( - featureCollection: FeatureCollection, - z: number, - x: number, - y: number -): Buffer | null { - featureCollection.features.push(...getCentroidFeatures(featureCollection)); - const tileIndex = geojsonvt(featureCollection, { - maxZoom: 24, // max zoom to preserve detail on; can't be higher than 24 - tolerance: 3, // simplification tolerance (higher means simpler) - extent: 4096, // tile extent (both width and height) - buffer: 64, // tile buffer on each side - debug: 0, // logging level (0 to disable, 1 or 2) - lineMetrics: false, // whether to enable line metrics tracking for LineString/MultiLineString features - promoteId: null, // name of a feature property to promote to feature.id. Cannot be used with `generateId` - generateId: false, // whether to generate feature ids. Cannot be used with `promoteId` - indexMaxZoom: 5, // max zoom in the initial tile index - indexMaxPoints: 100000, // max number of points per tile in the index - }); - const tile = tileIndex.getTile(z, x, y); - - if (tile) { - const pbf = vtpbf.fromGeojsonVt({ [MVT_SOURCE_LAYER_NAME]: tile }, { version: 2 }); - return Buffer.from(pbf); - } else { - return null; - } -} diff --git a/x-pack/plugins/maps/server/mvt/mvt_routes.ts b/x-pack/plugins/maps/server/mvt/mvt_routes.ts index 01a89aff1a661..3c61a47a383d6 100644 --- a/x-pack/plugins/maps/server/mvt/mvt_routes.ts +++ b/x-pack/plugins/maps/server/mvt/mvt_routes.ts @@ -16,10 +16,10 @@ import { MVT_GETTILE_API_PATH, API_ROOT_PATH, MVT_GETGRIDTILE_API_PATH, - ES_GEO_FIELD_TYPE, RENDER_AS, } from '../../common/constants'; -import { getGridTile, getTile } from './get_tile'; +import { getEsTile } from './get_tile'; +import { getEsGridTile } from './get_grid_tile'; const CACHE_TIMEOUT_SECONDS = 60 * 60; @@ -43,8 +43,6 @@ export function initMVTRoutes({ geometryFieldName: schema.string(), requestBody: schema.string(), index: schema.string(), - geoFieldType: schema.string(), - searchSessionId: schema.maybe(schema.string()), token: schema.maybe(schema.string()), }), }, @@ -56,14 +54,15 @@ export function initMVTRoutes({ ) => { const { query, params } = request; - const abortController = new AbortController(); - request.events.aborted$.subscribe(() => { - abortController.abort(); - }); + // todo - replace with direct abortion of raw transport request + // const abortController = new AbortController(); + // request.events.aborted$.subscribe(() => { + // abortController.abort(); + // }); const requestBodyDSL = rison.decode(query.requestBody as string); - const tile = await getTile({ + const tile = await getEsTile({ logger, context, geometryFieldName: query.geometryFieldName as string, @@ -72,9 +71,6 @@ export function initMVTRoutes({ z: parseInt((params as any).z, 10) as number, index: query.index as string, requestBody: requestBodyDSL as any, - geoFieldType: query.geoFieldType as ES_GEO_FIELD_TYPE, - searchSessionId: query.searchSessionId, - abortSignal: abortController.signal, }); return sendResponse(response, tile); @@ -95,8 +91,6 @@ export function initMVTRoutes({ requestBody: schema.string(), index: schema.string(), requestType: schema.string(), - geoFieldType: schema.string(), - searchSessionId: schema.maybe(schema.string()), token: schema.maybe(schema.string()), }), }, @@ -107,14 +101,16 @@ export function initMVTRoutes({ response: KibanaResponseFactory ) => { const { query, params } = request; - const abortController = new AbortController(); - request.events.aborted$.subscribe(() => { - abortController.abort(); - }); + + // todo - replace with direct abortion of raw transport request + // const abortController = new AbortController(); + // request.events.aborted$.subscribe(() => { + // abortController.abort(); + // }); const requestBodyDSL = rison.decode(query.requestBody as string); - const tile = await getGridTile({ + const tile = await getEsGridTile({ logger, context, geometryFieldName: query.geometryFieldName as string, @@ -124,9 +120,6 @@ export function initMVTRoutes({ index: query.index as string, requestBody: requestBodyDSL as any, requestType: query.requestType as RENDER_AS.POINT | RENDER_AS.GRID, - geoFieldType: query.geoFieldType as ES_GEO_FIELD_TYPE, - searchSessionId: query.searchSessionId, - abortSignal: abortController.signal, }); return sendResponse(response, tile); diff --git a/x-pack/plugins/maps/server/mvt/util.ts b/x-pack/plugins/maps/server/mvt/util.ts deleted file mode 100644 index b3dc606ba3f11..0000000000000 --- a/x-pack/plugins/maps/server/mvt/util.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// This implementation: -// - does not include meta-fields -// - does not validate the schema against the index-pattern (e.g. nested fields) -// In the context of .mvt this is sufficient: -// - only fields from the response are packed in the tile (more efficient) -// - query-dsl submitted from the client, which was generated by the IndexPattern -// todo: Ideally, this should adapt/reuse from https://github.com/elastic/kibana/blob/52b42a81faa9dd5c102b9fbb9a645748c3623121/src/plugins/data/common/index_patterns/index_patterns/flatten_hit.ts#L26 - -export function flattenHit( - geometryField: string, - hit: Record -): Record { - const flat: Record = {}; - if (hit) { - flattenSource(flat, '', hit._source as Record, geometryField); - if (hit.fields) { - flattenFields(flat, hit.fields as Array>); - } - - // Attach meta fields - flat._index = hit._index; - flat._id = hit._id; - } - return flat; -} - -function flattenSource( - accum: Record, - path: string, - properties: Record = {}, - geometryField: string -): Record { - accum = accum || {}; - for (const key in properties) { - if (properties.hasOwnProperty(key)) { - const newKey = path ? path + '.' + key : key; - let value; - if (geometryField === newKey) { - value = properties[key]; // do not deep-copy the geometry - } else if (properties[key] !== null && typeof value === 'object' && !Array.isArray(value)) { - value = flattenSource( - accum, - newKey, - properties[key] as Record, - geometryField - ); - } else { - value = properties[key]; - } - accum[newKey] = value; - } - } - return accum; -} - -function flattenFields(accum: Record = {}, fields: Array>) { - accum = accum || {}; - for (const key in fields) { - if (fields.hasOwnProperty(key)) { - const value = fields[key]; - if (Array.isArray(value)) { - accum[key] = value[0]; - } else { - accum[key] = value; - } - } - } -} diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts index 7291f7bbfa838..861b3727cea1b 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts @@ -107,6 +107,11 @@ export const getScatterplotMatrixVegaLiteSpec = ( labelColor: euiTheme.euiTextSubduedColor, titleColor: euiTheme.euiTextSubduedColor, }, + legend: { + orient: 'right', + labelColor: euiTheme.euiTextSubduedColor, + titleColor: euiTheme.euiTextSubduedColor, + }, }, repeat: { column: vegaColumns, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index fb103886635a9..00a63bcf2a414 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -26,6 +26,7 @@ import { useMlKibana } from '../../../../../contexts/kibana'; // Separate imports for lazy loadable VegaChart and related code import { VegaChart } from '../../../../../components/vega_chart'; import { VegaChartLoading } from '../../../../../components/vega_chart/vega_chart_loading'; +import { useCurrentEuiTheme } from '../../../../../components/color_range_legend'; import { ErrorCallout } from '../error_callout'; import { getDependentVar, DataFrameAnalyticsConfig } from '../../../../common'; @@ -33,6 +34,7 @@ import { DataFrameTaskStateType } from '../../../analytics_management/components import { ResultsSearchQuery } from '../../../../common/analytics'; import { ExpandableSection, HEADER_ITEMS_LOADING } from '../expandable_section'; + import { EvaluateStat } from './evaluate_stat'; import { EvaluationQualityMetricsTable } from './evaluation_quality_metrics_table'; @@ -107,6 +109,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se const { services: { docLinks }, } = useMlKibana(); + const { euiTheme } = useCurrentEuiTheme(); const [columns, setColumns] = useState([]); const [columnsData, setColumnsData] = useState([]); @@ -469,7 +472,8 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se vegaSpec={getRocCurveChartVegaLiteSpec( classificationClasses, rocCurveData, - getDependentVar(jobConfig.analysis) + getDependentVar(jobConfig.analysis), + euiTheme )} /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx index e9a6925476b02..ef5bcb83e871f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx @@ -10,6 +10,7 @@ import type { TopLevelSpec } from 'vega-lite/build/vega-lite'; import { euiPaletteColorBlind, euiPaletteGray } from '@elastic/eui'; +import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; @@ -43,7 +44,8 @@ export interface RocCurveDataRow extends RocCurveItem { export const getRocCurveChartVegaLiteSpec = ( classificationClasses: string[], data: RocCurveDataRow[], - legendTitle: string + legendTitle: string, + euiTheme: typeof euiThemeLight ): TopLevelSpec => { // we append two rows which make up the data for the diagonal baseline data.push({ tpr: 0, fpr: 0, threshold: 1, class_name: BASELINE }); @@ -59,6 +61,8 @@ export const getRocCurveChartVegaLiteSpec = ( config: { legend: { orient: 'right', + labelColor: euiTheme.euiTextSubduedColor, + titleColor: euiTheme.euiTextSubduedColor, }, view: { continuousHeight: SIZE, @@ -101,9 +105,9 @@ export const getRocCurveChartVegaLiteSpec = ( type: 'quantitative', axis: { tickColor: GRAY, - labelColor: GRAY, + labelColor: euiTheme.euiTextSubduedColor, domainColor: GRAY, - titleColor: GRAY, + titleColor: euiTheme.euiTextSubduedColor, }, }, y: { @@ -114,9 +118,9 @@ export const getRocCurveChartVegaLiteSpec = ( type: 'quantitative', axis: { tickColor: GRAY, - labelColor: GRAY, + labelColor: euiTheme.euiTextSubduedColor, domainColor: GRAY, - titleColor: GRAY, + titleColor: euiTheme.euiTextSubduedColor, }, }, tooltip: [ diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx index b9e866d2e00d0..04543a28ab3e6 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx @@ -19,10 +19,7 @@ import { IndexDataVisualizerPage as Page } from '../../../datavisualizer/index_b import { checkBasicLicense } from '../../../license'; import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; import { loadIndexPatterns } from '../../../util/index_utils'; -import { checkMlNodesAvailable } from '../../../ml_nodes_check'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -import { ML_PAGES } from '../../../../../common/constants/locator'; -import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; export const indexBasedRouteFactory = ( navigateToPath: NavigateToPath, @@ -44,16 +41,12 @@ export const indexBasedRouteFactory = ( const PageWrapper: FC = ({ location, deps }) => { const { redirectToMlAccessDeniedPage } = deps; - const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( - ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE - ); const { index, savedSearchId }: Record = parse(location.search, { sort: false }); const { context } = useResolver(index, savedSearchId, deps.config, { checkBasicLicense, loadIndexPatterns: () => loadIndexPatterns(deps.indexPatterns), checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx index 8500d85d5580a..53057cb16c132 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx @@ -19,9 +19,6 @@ import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; import { checkBasicLicense } from '../../../license'; import { loadIndexPatterns } from '../../../util/index_utils'; import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; -import { checkMlNodesAvailable } from '../../../ml_nodes_check'; -import { ML_PAGES } from '../../../../../common/constants/locator'; -import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; enum MODE { NEW_JOB, @@ -85,9 +82,6 @@ const PageWrapper: FC = ({ nextStepPath, deps, mode }) = } = useMlKibana(); const { redirectToMlAccessDeniedPage } = deps; - const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( - ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE - ); const newJobResolvers = { ...basicResolvers(deps), @@ -98,7 +92,6 @@ const PageWrapper: FC = ({ nextStepPath, deps, mode }) = checkBasicLicense, loadIndexPatterns: () => loadIndexPatterns(deps.indexPatterns), checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), - checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }; const { context } = useResolver( diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx index 1f56ea22839e2..cf9b5628222f4 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx @@ -46,7 +46,7 @@ export const LogStashPipelinePage: React.FC = ({ clusters }) => cluster_uuid: clusterUuid, }) as any; const [data, setData] = useState({} as any); - const [detailVertexId, setDetailVertexId] = useState(null); + const [detailVertexId, setDetailVertexId] = useState(undefined); const { updateTotalItemCount } = useTable('logstash.pipelines'); const title = i18n.translate('xpack.monitoring.logstash.pipeline.routeTitle', { @@ -128,18 +128,19 @@ export const LogStashPipelinePage: React.FC = ({ clusters }) => const timeseriesTooltipXValueFormatter = (xValue: any) => moment(xValue).format(dateFormat); const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); - const onVertexChange = useCallback( - (vertex: any) => { - if (!vertex) { - setDetailVertexId(null); - } else { - setDetailVertexId(vertex.id); - } + const onVertexChange = useCallback((vertex: any) => { + if (!vertex) { + setDetailVertexId(null); + } else { + setDetailVertexId(vertex.id); + } + }, []); + useEffect(() => { + if (detailVertexId !== undefined) { getPageData(); - }, - [getPageData] - ); + } + }, [detailVertexId, getPageData]); const onChangePipelineHash = useCallback(() => { window.location.hash = getSafeForExternalLink( diff --git a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx index c0030cfcfe55c..a508714612c28 100644 --- a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx @@ -49,6 +49,7 @@ export const PageTemplate: React.FC = ({ const { currentTimerange } = useContext(MonitoringTimeContainer.Context); const [loaded, setLoaded] = useState(false); + const [isRequestPending, setIsRequestPending] = useState(false); const history = useHistory(); const [hasError, setHasError] = useState(false); const handleRequestError = useRequestErrorHandler(); @@ -62,6 +63,7 @@ export const PageTemplate: React.FC = ({ ); useEffect(() => { + setIsRequestPending(true); getPageData?.() .then(getPageDataResponseHandler) .catch((err: IHttpFetchError) => { @@ -70,11 +72,20 @@ export const PageTemplate: React.FC = ({ }) .finally(() => { setLoaded(true); + setIsRequestPending(false); }); }, [getPageData, currentTimerange, getPageDataResponseHandler, handleRequestError]); const onRefresh = () => { - getPageData?.().then(getPageDataResponseHandler).catch(handleRequestError); + // don't refresh when a request is pending + if (isRequestPending) return; + setIsRequestPending(true); + getPageData?.() + .then(getPageDataResponseHandler) + .catch(handleRequestError) + .finally(() => { + setIsRequestPending(false); + }); if (isSetupModeFeatureEnabled(SetupModeFeature.MetricbeatMigration)) { updateSetupModeData(); diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap b/x-pack/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap index faaf0762dbc7a..3647945c094cb 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/ccr_shard/__snapshots__/ccr_shard.test.js.snap @@ -136,6 +136,8 @@ exports[`CcrShard that it renders normally 1`] = ` } + buttonElement="button" + element="div" id="ccrLatestStat" initialIsOpen={false} isLoading={false} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx index 03fd23631f755..097ea89826c38 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx @@ -12,7 +12,8 @@ import { FilterLabel } from './filter_label'; import * as useSeriesHook from '../hooks/use_series_filters'; import { buildFilterLabel } from '../../filter_value_label/filter_value_label'; -describe('FilterLabel', function () { +// FLAKY: https://github.com/elastic/kibana/issues/115324 +describe.skip('FilterLabel', function () { mockAppIndexPattern(); const invertFilter = jest.fn(); diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index 77595d1187093..22a469dbedbdd 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -32,8 +32,8 @@ export const config: PluginConfigDescriptor = { index: schema.string({ defaultValue: 'observability-annotations' }), }), unsafe: schema.object({ - alertingExperience: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), - cases: schema.object({ enabled: schema.boolean({ defaultValue: false }) }), + alertingExperience: schema.object({ enabled: schema.boolean({ defaultValue: true }) }), + cases: schema.object({ enabled: schema.boolean({ defaultValue: true }) }), }), }), }; diff --git a/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap b/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap index c5d87410f596f..40cf48555a8c0 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap +++ b/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap @@ -259,6 +259,8 @@ exports[`ScreenCapturePanelContent properly renders a view with "canvas" layout - + + + + + +