From 13baa5156151af258249afc6468f15b53fffeee0 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 23 Mar 2020 23:08:44 +0100 Subject: [PATCH 01/64] [APM] Collect telemetry about data/API performance (#51612) * [APM] Collect telemetry about data/API performance Closes #50757. * Ignore apm scripts package.json * Config flag for enabling/disabling telemetry collection --- src/dev/run_check_lockfile_symlinks.js | 2 + x-pack/legacy/plugins/apm/index.ts | 19 +- x-pack/legacy/plugins/apm/mappings.json | 773 +++++++++++++++++- x-pack/legacy/plugins/apm/scripts/.gitignore | 1 + .../legacy/plugins/apm/scripts/package.json | 10 + .../apm/scripts/setup-kibana-security.js | 1 + .../apm/scripts/upload-telemetry-data.js | 21 + .../download-telemetry-template.ts | 26 + .../generate-sample-documents.ts | 124 +++ .../scripts/upload-telemetry-data/index.ts | 208 +++++ .../elasticsearch_fieldnames.test.ts.snap | 48 +- x-pack/plugins/apm/common/agent_name.ts | 44 +- .../apm/common/apm_saved_object_constants.ts | 10 +- .../common/elasticsearch_fieldnames.test.ts | 15 +- .../apm/common/elasticsearch_fieldnames.ts | 11 +- x-pack/plugins/apm/kibana.json | 7 +- x-pack/plugins/apm/server/index.ts | 6 +- .../lib/apm_telemetry/__test__/index.test.ts | 83 -- .../collect_data_telemetry/index.ts | 77 ++ .../collect_data_telemetry/tasks.ts | 725 ++++++++++++++++ .../apm/server/lib/apm_telemetry/index.ts | 155 +++- .../apm/server/lib/apm_telemetry/types.ts | 118 +++ .../server/lib/helpers/setup_request.test.ts | 13 + x-pack/plugins/apm/server/plugin.ts | 37 +- .../server/routes/create_api/index.test.ts | 1 + x-pack/plugins/apm/server/routes/services.ts | 16 - .../apm/typings/elasticsearch/aggregations.ts | 26 +- .../apm/typings/es_schemas/raw/error_raw.ts | 2 + .../typings/es_schemas/raw/fields/observer.ts | 10 + .../apm/typings/es_schemas/raw/span_raw.ts | 2 + .../typings/es_schemas/raw/transaction_raw.ts | 2 + 31 files changed, 2387 insertions(+), 206 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/scripts/.gitignore create mode 100644 x-pack/legacy/plugins/apm/scripts/package.json create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/types.ts create mode 100644 x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts diff --git a/src/dev/run_check_lockfile_symlinks.js b/src/dev/run_check_lockfile_symlinks.js index 54a8cdf638a78..6c6fc54638ee8 100644 --- a/src/dev/run_check_lockfile_symlinks.js +++ b/src/dev/run_check_lockfile_symlinks.js @@ -36,6 +36,8 @@ const IGNORE_FILE_GLOBS = [ '**/*fixtures*/**/*', // cypress isn't used in production, ignore it 'x-pack/legacy/plugins/apm/e2e/*', + // apm scripts aren't used in production, ignore them + 'x-pack/legacy/plugins/apm/scripts/*', ]; run(async ({ log }) => { diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 0107997f233fe..594e8a4a7af72 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -14,7 +14,13 @@ import mappings from './mappings.json'; export const apm: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ - require: ['kibana', 'elasticsearch', 'xpack_main', 'apm_oss'], + require: [ + 'kibana', + 'elasticsearch', + 'xpack_main', + 'apm_oss', + 'task_manager' + ], id: 'apm', configPrefix: 'xpack.apm', publicDir: resolve(__dirname, 'public'), @@ -71,7 +77,10 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true), + + // telemetry + telemetryCollectionEnabled: Joi.boolean().default(true) }).default(); }, @@ -107,10 +116,12 @@ export const apm: LegacyPluginInitializer = kibana => { } } }); - const apmPlugin = server.newPlatform.setup.plugins .apm as APMPluginContract; - apmPlugin.registerLegacyAPI({ server }); + + apmPlugin.registerLegacyAPI({ + server + }); } }); }; diff --git a/x-pack/legacy/plugins/apm/mappings.json b/x-pack/legacy/plugins/apm/mappings.json index 61bc90da28756..ba4c7a89ceaa8 100644 --- a/x-pack/legacy/plugins/apm/mappings.json +++ b/x-pack/legacy/plugins/apm/mappings.json @@ -1,20 +1,659 @@ { - "apm-services-telemetry": { + "apm-telemetry": { "properties": { + "agents": { + "properties": { + "dotnet": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "go": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "java": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "js-base": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "type": "object" + } + } + } + } + }, + "nodejs": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "python": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "ruby": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "rum-js": { + "properties": { + "agent": { + "type": "object" + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "type": "object" + }, + "runtime": { + "type": "object" + } + } + } + } + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, "has_any_services": { "type": "boolean" }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, "services_per_agent": { "properties": { - "python": { + "dotnet": { "type": "long", "null_value": 0 }, - "java": { + "go": { "type": "long", "null_value": 0 }, - "nodejs": { + "java": { "type": "long", "null_value": 0 }, @@ -22,11 +661,11 @@ "type": "long", "null_value": 0 }, - "rum-js": { + "nodejs": { "type": "long", "null_value": 0 }, - "dotnet": { + "python": { "type": "long", "null_value": 0 }, @@ -34,11 +673,131 @@ "type": "long", "null_value": 0 }, - "go": { + "rum-js": { "type": "long", "null_value": 0 } } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } } } }, diff --git a/x-pack/legacy/plugins/apm/scripts/.gitignore b/x-pack/legacy/plugins/apm/scripts/.gitignore new file mode 100644 index 0000000000000..8ee01d321b721 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/.gitignore @@ -0,0 +1 @@ +yarn.lock diff --git a/x-pack/legacy/plugins/apm/scripts/package.json b/x-pack/legacy/plugins/apm/scripts/package.json new file mode 100644 index 0000000000000..9121449c53619 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/package.json @@ -0,0 +1,10 @@ +{ + "name": "apm-scripts", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@octokit/rest": "^16.35.0", + "console-stamp": "^0.2.9" + } +} diff --git a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js index 825c1a526fcc5..61ba2fdc7f7e3 100644 --- a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js +++ b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js @@ -16,6 +16,7 @@ ******************************/ // compile typescript on the fly +// eslint-disable-next-line import/no-extraneous-dependencies require('@babel/register')({ extensions: ['.ts'], plugins: ['@babel/plugin-proposal-optional-chaining'], diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js new file mode 100644 index 0000000000000..a99651c62dd7a --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// compile typescript on the fly +// eslint-disable-next-line import/no-extraneous-dependencies +require('@babel/register')({ + extensions: ['.ts'], + plugins: [ + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-proposal-nullish-coalescing-operator' + ], + presets: [ + '@babel/typescript', + ['@babel/preset-env', { targets: { node: 'current' } }] + ] +}); + +require('./upload-telemetry-data/index.ts'); diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts new file mode 100644 index 0000000000000..dfed9223ef708 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-ignore +import { Octokit } from '@octokit/rest'; + +export async function downloadTelemetryTemplate(octokit: Octokit) { + const file = await octokit.repos.getContents({ + owner: 'elastic', + repo: 'telemetry', + path: 'config/templates/xpack-phone-home.json', + // @ts-ignore + mediaType: { + format: 'application/vnd.github.VERSION.raw' + } + }); + + if (Array.isArray(file.data)) { + throw new Error('Expected single response, got array'); + } + + return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts new file mode 100644 index 0000000000000..8d76063a7fdf6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { DeepPartial } from 'utility-types'; +import { + merge, + omit, + defaultsDeep, + range, + mapValues, + isPlainObject, + flatten +} from 'lodash'; +import uuid from 'uuid'; +import { + CollectTelemetryParams, + collectDataTelemetry + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; + +interface GenerateOptions { + days: number; + instances: number; + variation: { + min: number; + max: number; + }; +} + +const randomize = ( + value: unknown, + instanceVariation: number, + dailyGrowth: number +) => { + if (typeof value === 'boolean') { + return Math.random() > 0.5; + } + if (typeof value === 'number') { + return Math.round(instanceVariation * dailyGrowth * value); + } + return value; +}; + +const mapValuesDeep = ( + obj: Record, + iterator: (value: unknown, key: string, obj: Record) => unknown +): Record => + mapValues(obj, (val, key) => + isPlainObject(val) ? mapValuesDeep(val, iterator) : iterator(val, key!, obj) + ); + +export async function generateSampleDocuments( + options: DeepPartial & { + collectTelemetryParams: CollectTelemetryParams; + } +) { + const { collectTelemetryParams, ...preferredOptions } = options; + + const opts: GenerateOptions = defaultsDeep( + { + days: 100, + instances: 50, + variation: { + min: 0.1, + max: 4 + } + }, + preferredOptions + ); + + const sample = await collectDataTelemetry(collectTelemetryParams); + + console.log('Collected telemetry'); // eslint-disable-line no-console + console.log('\n' + JSON.stringify(sample, null, 2)); // eslint-disable-line no-console + + const dateOfScriptExecution = new Date(); + + return flatten( + range(0, opts.instances).map(instanceNo => { + const instanceId = uuid.v4(); + const defaults = { + cluster_uuid: instanceId, + stack_stats: { + kibana: { + versions: { + version: '8.0.0' + } + } + } + }; + + const instanceVariation = + Math.random() * (opts.variation.max - opts.variation.min) + + opts.variation.min; + + return range(0, opts.days).map(dayNo => { + const dailyGrowth = Math.pow(1.005, opts.days - 1 - dayNo); + + const timestamp = Date.UTC( + dateOfScriptExecution.getFullYear(), + dateOfScriptExecution.getMonth(), + -dayNo + ); + + const generated = mapValuesDeep(omit(sample, 'versions'), value => + randomize(value, instanceVariation, dailyGrowth) + ); + + return merge({}, defaults, { + timestamp, + stack_stats: { + kibana: { + plugins: { + apm: merge({}, sample, generated) + } + } + } + }); + }); + }) + ); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts new file mode 100644 index 0000000000000..bdc57eac412fc --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// This script downloads the telemetry mapping, runs the APM telemetry tasks, +// generates a bunch of randomized data based on the downloaded sample, +// and uploads it to a cluster of your choosing in the same format as it is +// stored in the telemetry cluster. Its purpose is twofold: +// - Easier testing of the telemetry tasks +// - Validate whether we can run the queries we want to on the telemetry data + +import fs from 'fs'; +import path from 'path'; +// @ts-ignore +import { Octokit } from '@octokit/rest'; +import { merge, chunk, flatten, pick, identity } from 'lodash'; +import axios from 'axios'; +import yaml from 'js-yaml'; +import { Client } from 'elasticsearch'; +import { argv } from 'yargs'; +import { promisify } from 'util'; +import { Logger } from 'kibana/server'; +// @ts-ignore +import consoleStamp from 'console-stamp'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { CollectTelemetryParams } from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; +import { downloadTelemetryTemplate } from './download-telemetry-template'; +import mapping from '../../mappings.json'; +import { generateSampleDocuments } from './generate-sample-documents'; + +consoleStamp(console, '[HH:MM:ss.l]'); + +const githubToken = process.env.GITHUB_TOKEN; + +if (!githubToken) { + throw new Error('GITHUB_TOKEN was not provided.'); +} + +const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); +const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); +const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); + +const xpackTelemetryIndexName = 'xpack-phone-home'; + +const loadedKibanaConfig = (yaml.safeLoad( + fs.readFileSync( + fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, + 'utf8' + ) +) || {}) as {}; + +const cliEsCredentials = pick( + { + 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, + 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, + 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST + }, + identity +) as { + 'elasticsearch.username': string; + 'elasticsearch.password': string; + 'elasticsearch.hosts': string; +}; + +const config = { + 'apm_oss.transactionIndices': 'apm-*', + 'apm_oss.metricsIndices': 'apm-*', + 'apm_oss.errorIndices': 'apm-*', + 'apm_oss.spanIndices': 'apm-*', + 'apm_oss.onboardingIndices': 'apm-*', + 'apm_oss.sourcemapIndices': 'apm-*', + 'elasticsearch.hosts': 'http://localhost:9200', + ...loadedKibanaConfig, + ...cliEsCredentials +}; + +async function uploadData() { + const octokit = new Octokit({ + auth: githubToken + }); + + const telemetryTemplate = await downloadTelemetryTemplate(octokit); + + const kibanaMapping = mapping['apm-telemetry']; + + const httpAuth = + config['elasticsearch.username'] && config['elasticsearch.password'] + ? { + username: config['elasticsearch.username'], + password: config['elasticsearch.password'] + } + : null; + + const client = new Client({ + host: config['elasticsearch.hosts'], + ...(httpAuth + ? { + httpAuth: `${httpAuth.username}:${httpAuth.password}` + } + : {}) + }); + + if (argv.clear) { + try { + await promisify(client.indices.delete.bind(client))({ + index: xpackTelemetryIndexName + }); + } catch (err) { + // 404 = index not found, totally okay + if (err.status !== 404) { + throw err; + } + } + } + + const axiosInstance = axios.create({ + baseURL: config['elasticsearch.hosts'], + ...(httpAuth ? { auth: httpAuth } : {}) + }); + + const newTemplate = merge(telemetryTemplate, { + settings: { + index: { mapping: { total_fields: { limit: 10000 } } } + } + }); + + // override apm mapping instead of merging + newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; + + await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); + + const sampleDocuments = await generateSampleDocuments({ + collectTelemetryParams: { + logger: (console as unknown) as Logger, + indices: { + ...config, + apmCustomLinkIndex: '.apm-custom-links', + apmAgentConfigurationIndex: '.apm-agent-configuration' + }, + search: body => { + return promisify(client.search.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + indicesStats: body => { + return promisify(client.indices.stats.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + transportRequest: (params => { + return axiosInstance[params.method](params.path); + }) as CollectTelemetryParams['transportRequest'] + } + }); + + const chunks = chunk(sampleDocuments, 250); + + await chunks.reduce>((prev, documents) => { + return prev.then(async () => { + const body = flatten( + documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) + ); + + return promisify(client.bulk.bind(client))({ + body, + refresh: true + }).then((response: any) => { + if (response.errors) { + const firstError = response.items.filter( + (item: any) => item.index.status >= 400 + )[0].index.error; + throw new Error(`Failed to upload documents: ${firstError.reason} `); + } + }); + }); + }, Promise.resolve()); +} + +uploadData() + .catch(e => { + if ('response' in e) { + if (typeof e.response === 'string') { + // eslint-disable-next-line no-console + console.log(e.response); + } else { + // eslint-disable-next-line no-console + console.log( + JSON.stringify( + e.response, + ['status', 'statusText', 'headers', 'data'], + 2 + ) + ); + } + } else { + // eslint-disable-next-line no-console + console.log(e); + } + process.exit(1); + }) + .then(() => { + // eslint-disable-next-line no-console + console.log('Finished uploading generated telemetry data'); + }); diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 897d4e979fce3..5de82a9ee8788 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -2,6 +2,8 @@ exports[`Error AGENT_NAME 1`] = `"java"`; +exports[`Error AGENT_VERSION 1`] = `"agent version"`; + exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; @@ -56,7 +58,7 @@ exports[`Error METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Error OBSERVER_LISTENING 1`] = `undefined`; -exports[`Error OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Error OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Error PARENT_ID 1`] = `"parentId"`; @@ -68,10 +70,20 @@ exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Error SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Error SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; + +exports[`Error SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; + exports[`Error SERVICE_NAME 1`] = `"service name"`; exports[`Error SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Error SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Error SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Error SERVICE_VERSION 1`] = `undefined`; exports[`Error SPAN_ACTION 1`] = `undefined`; @@ -112,10 +124,14 @@ exports[`Error URL_FULL 1`] = `undefined`; exports[`Error USER_AGENT_NAME 1`] = `undefined`; +exports[`Error USER_AGENT_ORIGINAL 1`] = `undefined`; + exports[`Error USER_ID 1`] = `undefined`; exports[`Span AGENT_NAME 1`] = `"java"`; +exports[`Span AGENT_VERSION 1`] = `"agent version"`; + exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; @@ -170,7 +186,7 @@ exports[`Span METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Span OBSERVER_LISTENING 1`] = `undefined`; -exports[`Span OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Span OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Span PARENT_ID 1`] = `"parentId"`; @@ -182,10 +198,20 @@ exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Span SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Span SERVICE_LANGUAGE_NAME 1`] = `undefined`; + +exports[`Span SERVICE_LANGUAGE_VERSION 1`] = `undefined`; + exports[`Span SERVICE_NAME 1`] = `"service name"`; exports[`Span SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Span SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Span SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Span SERVICE_VERSION 1`] = `undefined`; exports[`Span SPAN_ACTION 1`] = `"my action"`; @@ -226,10 +252,14 @@ exports[`Span URL_FULL 1`] = `undefined`; exports[`Span USER_AGENT_NAME 1`] = `undefined`; +exports[`Span USER_AGENT_ORIGINAL 1`] = `undefined`; + exports[`Span USER_ID 1`] = `undefined`; exports[`Transaction AGENT_NAME 1`] = `"java"`; +exports[`Transaction AGENT_VERSION 1`] = `"agent version"`; + exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; @@ -284,7 +314,7 @@ exports[`Transaction METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`; -exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Transaction PARENT_ID 1`] = `"parentId"`; @@ -296,10 +326,20 @@ exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Transaction SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Transaction SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; + +exports[`Transaction SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; + exports[`Transaction SERVICE_NAME 1`] = `"service name"`; exports[`Transaction SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Transaction SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Transaction SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Transaction SERVICE_VERSION 1`] = `undefined`; exports[`Transaction SPAN_ACTION 1`] = `undefined`; @@ -340,4 +380,6 @@ exports[`Transaction URL_FULL 1`] = `"http://www.elastic.co"`; exports[`Transaction USER_AGENT_NAME 1`] = `"Other"`; +exports[`Transaction USER_AGENT_ORIGINAL 1`] = `"test original"`; + exports[`Transaction USER_ID 1`] = `"1337"`; diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index bb68eb88b8e18..085828b729ea5 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -4,36 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AgentName } from '../typings/es_schemas/ui/fields/agent'; + /* * Agent names can be any string. This list only defines the official agents * that we might want to target specifically eg. linking to their documentation * & telemetry reporting. Support additional agent types by appending * definitions in mappings.json (for telemetry), the AgentName type, and the - * agentNames object. + * AGENT_NAMES array. */ -import { AgentName } from '../typings/es_schemas/ui/fields/agent'; -const agentNames: { [agentName in AgentName]: agentName } = { - python: 'python', - java: 'java', - nodejs: 'nodejs', - 'js-base': 'js-base', - 'rum-js': 'rum-js', - dotnet: 'dotnet', - ruby: 'ruby', - go: 'go' -}; +export const AGENT_NAMES: AgentName[] = [ + 'java', + 'js-base', + 'rum-js', + 'dotnet', + 'go', + 'java', + 'nodejs', + 'python', + 'ruby' +]; -export function isAgentName(agentName: string): boolean { - return Object.values(agentNames).includes(agentName as AgentName); +export function isAgentName(agentName: string): agentName is AgentName { + return AGENT_NAMES.includes(agentName as AgentName); } -export function isRumAgentName(agentName: string | undefined) { - return ( - agentName === agentNames['js-base'] || agentName === agentNames['rum-js'] - ); +export function isRumAgentName( + agentName: string | undefined +): agentName is 'js-base' | 'rum-js' { + return agentName === 'js-base' || agentName === 'rum-js'; } -export function isJavaAgentName(agentName: string | undefined) { - return agentName === agentNames.java; +export function isJavaAgentName( + agentName: string | undefined +): agentName is 'java' { + return agentName === 'java'; } diff --git a/x-pack/plugins/apm/common/apm_saved_object_constants.ts b/x-pack/plugins/apm/common/apm_saved_object_constants.ts index ac43b700117c6..0529d90fe940a 100644 --- a/x-pack/plugins/apm/common/apm_saved_object_constants.ts +++ b/x-pack/plugins/apm/common/apm_saved_object_constants.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -// APM Services telemetry -export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE = - 'apm-services-telemetry'; -export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID = 'apm-services-telemetry'; +// the types have to match the names of the saved object mappings +// in /x-pack/legacy/plugins/apm/mappings.json // APM indices export const APM_INDICES_SAVED_OBJECT_TYPE = 'apm-indices'; export const APM_INDICES_SAVED_OBJECT_ID = 'apm-indices'; + +// APM telemetry +export const APM_TELEMETRY_SAVED_OBJECT_TYPE = 'apm-telemetry'; +export const APM_TELEMETRY_SAVED_OBJECT_ID = 'apm-telemetry'; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts index 1add2427d16a0..63fa749cd9f2c 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts @@ -15,7 +15,10 @@ describe('Transaction', () => { const transaction: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' @@ -63,7 +66,10 @@ describe('Span', () => { const span: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' @@ -107,7 +113,10 @@ describe('Span', () => { describe('Error', () => { const errorDoc: AllowUnknownProperties = { '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 822201baddd88..bc1b346f50da7 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -4,15 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -export const AGENT_NAME = 'agent.name'; export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; +export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version'; +export const SERVICE_LANGUAGE_NAME = 'service.language.name'; +export const SERVICE_LANGUAGE_VERSION = 'service.language.version'; +export const SERVICE_RUNTIME_NAME = 'service.runtime.name'; +export const SERVICE_RUNTIME_VERSION = 'service.runtime.version'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; + +export const AGENT_NAME = 'agent.name'; +export const AGENT_VERSION = 'agent.version'; + export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; +export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; export const DESTINATION_ADDRESS = 'destination.address'; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 96579377c95e8..dadb1dff6d7a9 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -3,8 +3,11 @@ "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "apm"], + "configPath": [ + "xpack", + "apm" + ], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection"] + "optionalPlugins": ["cloud", "usageCollection", "taskManager"] } diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 8afdb9e99c1a3..77655568a7e9c 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -29,7 +29,8 @@ export const config = { enabled: schema.boolean({ defaultValue: true }), transactionGroupBucketSize: schema.number({ defaultValue: 100 }), maxTraceItems: schema.number({ defaultValue: 1000 }) - }) + }), + telemetryCollectionEnabled: schema.boolean({ defaultValue: true }) }) }; @@ -62,7 +63,8 @@ export function mergeConfigs( 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, - 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern + 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern, + 'xpack.apm.telemetryCollectionEnabled': apmConfig.telemetryCollectionEnabled }; } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts deleted file mode 100644 index c45c74a791aee..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { SavedObjectAttributes } from '../../../../../../../src/core/server'; -import { createApmTelementry, storeApmServicesTelemetry } from '../index'; -import { - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID -} from '../../../../common/apm_saved_object_constants'; - -describe('apm_telemetry', () => { - describe('createApmTelementry', () => { - it('should create a ApmTelemetry object with boolean flag and frequency map of the given list of AgentNames', () => { - const apmTelemetry = createApmTelementry([ - 'go', - 'nodejs', - 'go', - 'js-base' - ]); - expect(apmTelemetry.has_any_services).toBe(true); - expect(apmTelemetry.services_per_agent).toMatchObject({ - go: 2, - nodejs: 1, - 'js-base': 1 - }); - }); - it('should ignore undefined or unknown AgentName values', () => { - const apmTelemetry = createApmTelementry([ - 'go', - 'nodejs', - 'go', - 'js-base', - 'example-platform' as any, - undefined as any - ]); - expect(apmTelemetry.services_per_agent).toMatchObject({ - go: 2, - nodejs: 1, - 'js-base': 1 - }); - }); - }); - - describe('storeApmServicesTelemetry', () => { - let apmTelemetry: SavedObjectAttributes; - let savedObjectsClient: any; - - beforeEach(() => { - apmTelemetry = { - has_any_services: true, - services_per_agent: { - go: 2, - nodejs: 1, - 'js-base': 1 - } - }; - savedObjectsClient = { create: jest.fn() }; - }); - - it('should call savedObjectsClient create with the given ApmTelemetry object', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][1]).toBe(apmTelemetry); - }); - - it('should call savedObjectsClient create with the apm-telemetry document type and ID', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][0]).toBe( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE - ); - expect(savedObjectsClient.create.mock.calls[0][2].id).toBe( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID - ); - }); - - it('should call savedObjectsClient create with overwrite: true', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][2].overwrite).toBe(true); - }); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts new file mode 100644 index 0000000000000..729ccb73d73f3 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { merge } from 'lodash'; +import { Logger, CallAPIOptions } from 'kibana/server'; +import { IndicesStatsParams, Client } from 'elasticsearch'; +import { + ESSearchRequest, + ESSearchResponse +} from '../../../../typings/elasticsearch'; +import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; +import { tasks } from './tasks'; +import { APMDataTelemetry } from '../types'; + +type TelemetryTaskExecutor = (params: { + indices: ApmIndicesConfig; + search( + params: TSearchRequest + ): Promise>; + indicesStats( + params: IndicesStatsParams, + options?: CallAPIOptions + ): ReturnType; + transportRequest: (params: { + path: string; + method: 'get'; + }) => Promise; +}) => Promise; + +export interface TelemetryTask { + name: string; + executor: TelemetryTaskExecutor; +} + +export type CollectTelemetryParams = Parameters[0] & { + logger: Logger; +}; + +export function collectDataTelemetry({ + search, + indices, + logger, + indicesStats, + transportRequest +}: CollectTelemetryParams) { + return tasks.reduce((prev, task) => { + return prev.then(async data => { + logger.debug(`Executing APM telemetry task ${task.name}`); + try { + const time = process.hrtime(); + const next = await task.executor({ + search, + indices, + indicesStats, + transportRequest + }); + const took = process.hrtime(time); + + return merge({}, data, next, { + tasks: { + [task.name]: { + took: { + ms: Math.round(took[0] * 1000 + took[1] / 1e6) + } + } + } + }); + } catch (err) { + logger.warn(`Failed executing APM telemetry task ${task.name}`); + logger.warn(err); + return data; + } + }); + }, Promise.resolve({} as APMDataTelemetry)); +} diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts new file mode 100644 index 0000000000000..415076b6ae116 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -0,0 +1,725 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { flatten, merge, sortBy, sum } from 'lodash'; +import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; +import { AGENT_NAMES } from '../../../../common/agent_name'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + AGENT_NAME, + AGENT_VERSION, + ERROR_GROUP_ID, + TRANSACTION_NAME, + PARENT_ID, + SERVICE_FRAMEWORK_NAME, + SERVICE_FRAMEWORK_VERSION, + SERVICE_LANGUAGE_NAME, + SERVICE_LANGUAGE_VERSION, + SERVICE_RUNTIME_NAME, + SERVICE_RUNTIME_VERSION, + USER_AGENT_ORIGINAL +} from '../../../../common/elasticsearch_fieldnames'; +import { Span } from '../../../../typings/es_schemas/ui/span'; +import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; +import { TelemetryTask } from '.'; +import { APMTelemetry } from '../types'; + +const TIME_RANGES = ['1d', 'all'] as const; +type TimeRange = typeof TIME_RANGES[number]; + +export const tasks: TelemetryTask[] = [ + { + name: 'processor_events', + executor: async ({ indices, search }) => { + const indicesByProcessorEvent = { + error: indices['apm_oss.errorIndices'], + metric: indices['apm_oss.metricsIndices'], + span: indices['apm_oss.spanIndices'], + transaction: indices['apm_oss.transactionIndices'], + onboarding: indices['apm_oss.onboardingIndices'], + sourcemap: indices['apm_oss.sourcemapIndices'] + }; + + type ProcessorEvent = keyof typeof indicesByProcessorEvent; + + const jobs: Array<{ + processorEvent: ProcessorEvent; + timeRange: TimeRange; + }> = flatten( + (Object.keys( + indicesByProcessorEvent + ) as ProcessorEvent[]).map(processorEvent => + TIME_RANGES.map(timeRange => ({ processorEvent, timeRange })) + ) + ); + + const allData = await jobs.reduce((prevJob, current) => { + return prevJob.then(async data => { + const { processorEvent, timeRange } = current; + + const response = await search({ + index: indicesByProcessorEvent[processorEvent], + body: { + size: 1, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: processorEvent } }, + ...(timeRange !== 'all' + ? [ + { + range: { + '@timestamp': { + gte: `now-${timeRange}` + } + } + } + ] + : []) + ] + } + }, + sort: { + '@timestamp': 'asc' + }, + _source: ['@timestamp'], + track_total_hits: true + } + }); + + const event = response.hits.hits[0]?._source as { + '@timestamp': number; + }; + + return merge({}, data, { + counts: { + [processorEvent]: { + [timeRange]: response.hits.total.value + } + }, + ...(timeRange === 'all' && event + ? { + retainment: { + [processorEvent]: { + ms: + new Date().getTime() - + new Date(event['@timestamp']).getTime() + } + } + } + : {}) + }); + }); + }, Promise.resolve({} as Record> }>)); + + return allData; + } + }, + { + name: 'agent_configuration', + executor: async ({ indices, search }) => { + const agentConfigurationCount = ( + await search({ + index: indices.apmAgentConfigurationIndex, + body: { + size: 0, + track_total_hits: true + } + }) + ).hits.total.value; + + return { + counts: { + agent_configuration: { + all: agentConfigurationCount + } + } + }; + } + }, + { + name: 'services', + executor: async ({ indices, search }) => { + const servicesPerAgent = await AGENT_NAMES.reduce( + (prevJob, agentName) => { + return prevJob.then(async data => { + const response = await search({ + index: [ + indices['apm_oss.errorIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { + term: { + [AGENT_NAME]: agentName + } + }, + { + range: { + '@timestamp': { + gte: 'now-1d' + } + } + } + ] + } + }, + aggs: { + services: { + cardinality: { + field: SERVICE_NAME + } + } + } + } + }); + + return { + ...data, + [agentName]: response.aggregations?.services.value || 0 + }; + }); + }, + Promise.resolve({} as Record) + ); + + return { + has_any_services: sum(Object.values(servicesPerAgent)) > 0, + services_per_agent: servicesPerAgent + }; + } + }, + { + name: 'versions', + executor: async ({ search, indices }) => { + const response = await search({ + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.errorIndices'] + ], + terminateAfter: 1, + body: { + query: { + exists: { + field: 'observer.version' + } + }, + size: 1, + sort: { + '@timestamp': 'desc' + } + } + }); + + const hit = response.hits.hits[0]?._source as Pick< + Transaction | Span | APMError, + 'observer' + >; + + if (!hit || !hit.observer?.version) { + return {}; + } + + const [major, minor, patch] = hit.observer.version + .split('.') + .map(part => Number(part)); + + return { + versions: { + apm_server: { + major, + minor, + patch + } + } + }; + } + }, + { + name: 'groupings', + executor: async ({ search, indices }) => { + const range1d = { range: { '@timestamp': { gte: 'now-1d' } } }; + const errorGroupsCount = ( + await search({ + index: indices['apm_oss.errorIndices'], + body: { + size: 0, + query: { + bool: { + filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d] + } + }, + aggs: { + top_service: { + terms: { + field: SERVICE_NAME, + order: { + error_groups: 'desc' + }, + size: 1 + }, + aggs: { + error_groups: { + cardinality: { + field: ERROR_GROUP_ID + } + } + } + } + } + } + }) + ).aggregations?.top_service.buckets[0]?.error_groups.value; + + const transactionGroupsCount = ( + await search({ + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + range1d + ] + } + }, + aggs: { + top_service: { + terms: { + field: SERVICE_NAME, + order: { + transaction_groups: 'desc' + }, + size: 1 + }, + aggs: { + transaction_groups: { + cardinality: { + field: TRANSACTION_NAME + } + } + } + } + } + } + }) + ).aggregations?.top_service.buckets[0]?.transaction_groups.value; + + const tracesPerDayCount = ( + await search({ + index: indices['apm_oss.transactionIndices'], + body: { + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + range1d + ], + must_not: { + exists: { field: PARENT_ID } + } + } + }, + track_total_hits: true, + size: 0 + } + }) + ).hits.total.value; + + const servicesCount = ( + await search({ + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [range1d] + } + }, + aggs: { + service_name: { + cardinality: { + field: SERVICE_NAME + } + } + } + } + }) + ).aggregations?.service_name.value; + + return { + counts: { + max_error_groups_per_service: { + '1d': errorGroupsCount || 0 + }, + max_transaction_groups_per_service: { + '1d': transactionGroupsCount || 0 + }, + traces: { + '1d': tracesPerDayCount || 0 + }, + services: { + '1d': servicesCount || 0 + } + } + }; + } + }, + { + name: 'integrations', + executor: async ({ transportRequest }) => { + const apmJobs = ['*-high_mean_response_time']; + + const response = (await transportRequest({ + method: 'get', + path: `/_ml/anomaly_detectors/${apmJobs.join(',')}` + })) as { data?: { count: number } }; + + return { + integrations: { + ml: { + all_jobs_count: response.data?.count ?? 0 + } + } + }; + } + }, + { + name: 'agents', + executor: async ({ search, indices }) => { + const size = 3; + + const agentData = await AGENT_NAMES.reduce(async (prevJob, agentName) => { + const data = await prevJob; + + const response = await search({ + index: [ + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [AGENT_NAME]: agentName } }, + { range: { '@timestamp': { gte: 'now-1d' } } } + ] + } + }, + sort: { + '@timestamp': 'desc' + }, + aggs: { + [AGENT_VERSION]: { + terms: { + field: AGENT_VERSION, + size + } + }, + [SERVICE_FRAMEWORK_NAME]: { + terms: { + field: SERVICE_FRAMEWORK_NAME, + size + }, + aggs: { + [SERVICE_FRAMEWORK_VERSION]: { + terms: { + field: SERVICE_FRAMEWORK_VERSION, + size + } + } + } + }, + [SERVICE_FRAMEWORK_VERSION]: { + terms: { + field: SERVICE_FRAMEWORK_VERSION, + size + } + }, + [SERVICE_LANGUAGE_NAME]: { + terms: { + field: SERVICE_LANGUAGE_NAME, + size + }, + aggs: { + [SERVICE_LANGUAGE_VERSION]: { + terms: { + field: SERVICE_LANGUAGE_VERSION, + size + } + } + } + }, + [SERVICE_LANGUAGE_VERSION]: { + terms: { + field: SERVICE_LANGUAGE_VERSION, + size + } + }, + [SERVICE_RUNTIME_NAME]: { + terms: { + field: SERVICE_RUNTIME_NAME, + size + }, + aggs: { + [SERVICE_RUNTIME_VERSION]: { + terms: { + field: SERVICE_RUNTIME_VERSION, + size + } + } + } + }, + [SERVICE_RUNTIME_VERSION]: { + terms: { + field: SERVICE_RUNTIME_VERSION, + size + } + } + } + } + }); + + const { aggregations } = response; + + if (!aggregations) { + return data; + } + + const toComposite = ( + outerKey: string | number, + innerKey: string | number + ) => `${outerKey}/${innerKey}`; + + return { + ...data, + [agentName]: { + agent: { + version: aggregations[AGENT_VERSION].buckets.map( + bucket => bucket.key as string + ) + }, + service: { + framework: { + name: aggregations[SERVICE_FRAMEWORK_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_FRAMEWORK_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_FRAMEWORK_NAME].buckets.map(bucket => + bucket[SERVICE_FRAMEWORK_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + }, + language: { + name: aggregations[SERVICE_LANGUAGE_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_LANGUAGE_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_LANGUAGE_NAME].buckets.map(bucket => + bucket[SERVICE_LANGUAGE_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + }, + runtime: { + name: aggregations[SERVICE_RUNTIME_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_RUNTIME_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_RUNTIME_NAME].buckets.map(bucket => + bucket[SERVICE_RUNTIME_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + } + } + } + }; + }, Promise.resolve({} as APMTelemetry['agents'])); + + return { + agents: agentData + }; + } + }, + { + name: 'indices_stats', + executor: async ({ indicesStats, indices }) => { + const response = await indicesStats({ + index: [ + indices.apmAgentConfigurationIndex, + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.onboardingIndices'], + indices['apm_oss.sourcemapIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.transactionIndices'] + ] + }); + + return { + indices: { + shards: { + total: response._shards.total + }, + all: { + total: { + docs: { + count: response._all.total.docs.count + }, + store: { + size_in_bytes: response._all.total.store.size_in_bytes + } + } + } + } + }; + } + }, + { + name: 'cardinality', + executor: async ({ search }) => { + const allAgentsCardinalityResponse = await search({ + body: { + size: 0, + query: { + bool: { + filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }] + } + }, + aggs: { + [TRANSACTION_NAME]: { + cardinality: { + field: TRANSACTION_NAME + } + }, + [USER_AGENT_ORIGINAL]: { + cardinality: { + field: USER_AGENT_ORIGINAL + } + } + } + } + }); + + const rumAgentCardinalityResponse = await search({ + body: { + size: 0, + query: { + bool: { + filter: [ + { range: { '@timestamp': { gte: 'now-1d' } } }, + { terms: { [AGENT_NAME]: ['rum-js', 'js-base'] } } + ] + } + }, + aggs: { + [TRANSACTION_NAME]: { + cardinality: { + field: TRANSACTION_NAME + } + }, + [USER_AGENT_ORIGINAL]: { + cardinality: { + field: USER_AGENT_ORIGINAL + } + } + } + } + }); + + return { + cardinality: { + transaction: { + name: { + all_agents: { + '1d': + allAgentsCardinalityResponse.aggregations?.[TRANSACTION_NAME] + .value + }, + rum: { + '1d': + rumAgentCardinalityResponse.aggregations?.[TRANSACTION_NAME] + .value + } + } + }, + user_agent: { + original: { + all_agents: { + '1d': + allAgentsCardinalityResponse.aggregations?.[ + USER_AGENT_ORIGINAL + ].value + }, + rum: { + '1d': + rumAgentCardinalityResponse.aggregations?.[ + USER_AGENT_ORIGINAL + ].value + } + } + } + } + }; + } + } +]; diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index a2b0494730826..c80057a2894dc 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -3,60 +3,127 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { countBy } from 'lodash'; -import { SavedObjectAttributes } from '../../../../../../src/core/server'; -import { isAgentName } from '../../../common/agent_name'; +import { CoreSetup, Logger } from 'src/core/server'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract +} from '../../../../task_manager/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + APM_TELEMETRY_SAVED_OBJECT_ID, + APM_TELEMETRY_SAVED_OBJECT_TYPE } from '../../../common/apm_saved_object_constants'; -import { UsageCollectionSetup } from '../../../../../../src/plugins/usage_collection/server'; -import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; - -export function createApmTelementry( - agentNames: string[] = [] -): SavedObjectAttributes { - const validAgentNames = agentNames.filter(isAgentName); - return { - has_any_services: validAgentNames.length > 0, - services_per_agent: countBy(validAgentNames) +import { + collectDataTelemetry, + CollectTelemetryParams +} from './collect_data_telemetry'; +import { APMConfig } from '../..'; +import { getInternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; + +const APM_TELEMETRY_TASK_NAME = 'apm-telemetry-task'; + +export async function createApmTelemetry({ + core, + config$, + usageCollector, + taskManager, + logger +}: { + core: CoreSetup; + config$: Observable; + usageCollector: UsageCollectionSetup; + taskManager: TaskManagerSetupContract; + logger: Logger; +}) { + const savedObjectsClient = await getInternalSavedObjectsClient(core); + + const collectAndStore = async () => { + const config = await config$.pipe(take(1)).toPromise(); + const esClient = core.elasticsearch.dataClient; + + const indices = await getApmIndices({ + config, + savedObjectsClient + }); + + const search = esClient.callAsInternalUser.bind( + esClient, + 'search' + ) as CollectTelemetryParams['search']; + + const indicesStats = esClient.callAsInternalUser.bind( + esClient, + 'indices.stats' + ) as CollectTelemetryParams['indicesStats']; + + const transportRequest = esClient.callAsInternalUser.bind( + esClient, + 'transport.request' + ) as CollectTelemetryParams['transportRequest']; + + const dataTelemetry = await collectDataTelemetry({ + search, + indices, + logger, + indicesStats, + transportRequest + }); + + await savedObjectsClient.create( + APM_TELEMETRY_SAVED_OBJECT_TYPE, + dataTelemetry, + { id: APM_TELEMETRY_SAVED_OBJECT_TYPE, overwrite: true } + ); }; -} -export async function storeApmServicesTelemetry( - savedObjectsClient: InternalSavedObjectsClient, - apmTelemetry: SavedObjectAttributes -) { - return savedObjectsClient.create( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - apmTelemetry, - { - id: APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID, - overwrite: true + taskManager.registerTaskDefinitions({ + [APM_TELEMETRY_TASK_NAME]: { + title: 'Collect APM telemetry', + type: APM_TELEMETRY_TASK_NAME, + createTaskRunner: () => { + return { + run: async () => { + await collectAndStore(); + } + }; + } } - ); -} + }); -export function makeApmUsageCollector( - usageCollector: UsageCollectionSetup, - savedObjectsRepository: InternalSavedObjectsClient -) { - const apmUsageCollector = usageCollector.makeUsageCollector({ + const collector = usageCollector.makeUsageCollector({ type: 'apm', fetch: async () => { - try { - const apmTelemetrySavedObject = await savedObjectsRepository.get( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID - ); - return apmTelemetrySavedObject.attributes; - } catch (err) { - return createApmTelementry(); - } + const data = ( + await savedObjectsClient.get( + APM_TELEMETRY_SAVED_OBJECT_TYPE, + APM_TELEMETRY_SAVED_OBJECT_ID + ) + ).attributes; + + return data; }, isReady: () => true }); - usageCollector.registerCollector(apmUsageCollector); + usageCollector.registerCollector(collector); + + core.getStartServices().then(([coreStart, pluginsStart]) => { + const { taskManager: taskManagerStart } = pluginsStart as { + taskManager: TaskManagerStartContract; + }; + + taskManagerStart.ensureScheduled({ + id: APM_TELEMETRY_TASK_NAME, + taskType: APM_TELEMETRY_TASK_NAME, + schedule: { + interval: '720m' + }, + scope: ['apm'], + params: {}, + state: {} + }); + }); } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts new file mode 100644 index 0000000000000..f68dc517a2227 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DeepPartial } from 'utility-types'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; + +export interface TimeframeMap { + '1d': number; + all: number; +} + +export type TimeframeMap1d = Pick; +export type TimeframeMapAll = Pick; + +export type APMDataTelemetry = DeepPartial<{ + has_any_services: boolean; + services_per_agent: Record; + versions: { + apm_server: { + minor: number; + major: number; + patch: number; + }; + }; + counts: { + transaction: TimeframeMap; + span: TimeframeMap; + error: TimeframeMap; + metric: TimeframeMap; + sourcemap: TimeframeMap; + onboarding: TimeframeMap; + agent_configuration: TimeframeMapAll; + max_transaction_groups_per_service: TimeframeMap; + max_error_groups_per_service: TimeframeMap; + traces: TimeframeMap; + services: TimeframeMap; + }; + cardinality: { + user_agent: { + original: { + all_agents: TimeframeMap1d; + rum: TimeframeMap1d; + }; + }; + transaction: { + name: { + all_agents: TimeframeMap1d; + rum: TimeframeMap1d; + }; + }; + }; + retainment: Record< + 'span' | 'transaction' | 'error' | 'metric' | 'sourcemap' | 'onboarding', + { ms: number } + >; + integrations: { + ml: { + all_jobs_count: number; + }; + }; + agents: Record< + AgentName, + { + agent: { + version: string[]; + }; + service: { + framework: { + name: string[]; + version: string[]; + composite: string[]; + }; + language: { + name: string[]; + version: string[]; + composite: string[]; + }; + runtime: { + name: string[]; + version: string[]; + composite: string[]; + }; + }; + } + >; + indices: { + shards: { + total: number; + }; + all: { + total: { + docs: { + count: number; + }; + store: { + size_in_bytes: number; + }; + }; + }; + }; + tasks: Record< + | 'processor_events' + | 'agent_configuration' + | 'services' + | 'versions' + | 'groupings' + | 'integrations' + | 'agents' + | 'indices_stats' + | 'cardinality', + { took: { ms: number } } + >; +}>; + +export type APMTelemetry = APMDataTelemetry; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 40a2a0e7216a0..8e8cf698a84cf 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -39,6 +39,19 @@ function getMockRequest() { _debug: false } }, + __LEGACY: { + server: { + plugins: { + elasticsearch: { + getCluster: jest.fn().mockReturnValue({ callWithInternalUser: {} }) + } + }, + savedObjects: { + SavedObjectsClient: jest.fn(), + getSavedObjectsRepository: jest.fn() + } + } + }, core: { elasticsearch: { dataClient: { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index db14730f802a9..a29b9399d8435 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -8,9 +8,9 @@ import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; import { once } from 'lodash'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { TaskManagerSetupContract } from '../../task_manager/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; -import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; @@ -21,6 +21,7 @@ import { tutorialProvider } from './tutorial'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; +import { createApmTelemetry } from './lib/apm_telemetry'; export interface LegacySetup { server: Server; @@ -47,9 +48,10 @@ export class APMPlugin implements Plugin { licensing: LicensingPluginSetup; cloud?: CloudSetup; usageCollection?: UsageCollectionSetup; + taskManager?: TaskManagerSetupContract; } ) { - const logger = this.initContext.logger.get('apm'); + const logger = this.initContext.logger.get(); const config$ = this.initContext.config.create(); const mergedConfig$ = combineLatest(plugins.apm_oss.config$, config$).pipe( map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) @@ -61,6 +63,20 @@ export class APMPlugin implements Plugin { const currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); + if ( + plugins.taskManager && + plugins.usageCollection && + currentConfig['xpack.apm.telemetryCollectionEnabled'] + ) { + createApmTelemetry({ + core, + config$: mergedConfig$, + usageCollector: plugins.usageCollection, + taskManager: plugins.taskManager, + logger + }); + } + // create agent configuration index without blocking setup lifecycle createApmAgentConfigurationIndex({ esClient: core.elasticsearch.dataClient, @@ -89,18 +105,6 @@ export class APMPlugin implements Plugin { }) ); - const usageCollection = plugins.usageCollection; - if (usageCollection) { - getInternalSavedObjectsClient(core) - .then(savedObjectsClient => { - makeApmUsageCollector(usageCollection, savedObjectsClient); - }) - .catch(error => { - logger.error('Unable to initialize use collection'); - logger.error(error.message); - }); - } - return { config$: mergedConfig$, registerLegacyAPI: once((__LEGACY: LegacySetup) => { @@ -115,6 +119,7 @@ export class APMPlugin implements Plugin { }; } - public start() {} + public async start() {} + public stop() {} } diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index e639bb5101e2f..312dae1d1f9d2 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -36,6 +36,7 @@ const getCoreMock = () => { put, createRouter, context: { + measure: () => undefined, config$: new BehaviorSubject({} as APMConfig), logger: ({ error: jest.fn() diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 2d4fae9d2707a..1c6561ee24c93 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,11 +5,6 @@ */ import * as t from 'io-ts'; -import { AgentName } from '../../typings/es_schemas/ui/fields/agent'; -import { - createApmTelementry, - storeApmServicesTelemetry -} from '../lib/apm_telemetry'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; @@ -18,7 +13,6 @@ import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadat import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; -import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; export const servicesRoute = createRoute(core => ({ path: '/api/apm/services', @@ -29,16 +23,6 @@ export const servicesRoute = createRoute(core => ({ const setup = await setupRequest(context, request); const services = await getServices(setup); - // Store telemetry data derived from services - const agentNames = services.items.map( - ({ agentName }) => agentName as AgentName - ); - const apmTelemetry = createApmTelementry(agentNames); - const savedObjectsClient = await getInternalSavedObjectsClient(core); - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry).catch(error => { - context.logger.error(error.message); - }); - return services; } })); diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 6d3620f11a87b..8a8d256cf4273 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -126,6 +126,16 @@ export interface AggregationOptionsByType { combine_script: Script; reduce_script: Script; }; + date_range: { + field: string; + format?: string; + ranges: Array< + | { from: string | number } + | { to: string | number } + | { from: string | number; to: string | number } + >; + keyed?: boolean; + }; } type AggregationType = keyof AggregationOptionsByType; @@ -136,6 +146,15 @@ type AggregationOptionsMap = Unionize< } > & { aggs?: AggregationInputMap }; +interface DateRangeBucket { + key: string; + to?: number; + from?: number; + to_as_string?: string; + from_as_string?: string; + doc_count: number; +} + export interface AggregationInputMap { [key: string]: AggregationOptionsMap; } @@ -276,6 +295,11 @@ interface AggregationResponsePart< scripted_metric: { value: unknown; }; + date_range: { + buckets: TAggregationOptionsMap extends { date_range: { keyed: true } } + ? Record + : { buckets: DateRangeBucket[] }; + }; } // Type for debugging purposes. If you see an error in AggregationResponseMap @@ -285,7 +309,7 @@ interface AggregationResponsePart< // type MissingAggregationResponseTypes = Exclude< // AggregationType, -// keyof AggregationResponsePart<{}> +// keyof AggregationResponsePart<{}, unknown> // >; export type AggregationResponseMap< diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts index daf65e44980b6..8e49d02beb908 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts @@ -15,6 +15,7 @@ import { Service } from './fields/service'; import { IStackframe } from './fields/stackframe'; import { Url } from './fields/url'; import { User } from './fields/user'; +import { Observer } from './fields/observer'; interface Processor { name: 'error'; @@ -61,4 +62,5 @@ export interface ErrorRaw extends APMBaseDoc { service: Service; url?: Url; user?: User; + observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts new file mode 100644 index 0000000000000..42843130ec47f --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Observer { + version: string; + version_major: number; +} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts index dbd9e7ede4256..4d5d2c5c4a12e 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts @@ -6,6 +6,7 @@ import { APMBaseDoc } from './apm_base_doc'; import { IStackframe } from './fields/stackframe'; +import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -50,4 +51,5 @@ export interface SpanRaw extends APMBaseDoc { transaction?: { id: string; }; + observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index 3673f1f13c403..b8ebb4cf8da51 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -15,6 +15,7 @@ import { Service } from './fields/service'; import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; +import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -61,4 +62,5 @@ export interface TransactionRaw extends APMBaseDoc { url?: Url; user?: User; user_agent?: UserAgent; + observer?: Observer; } From dc31736dd28187750fd3cd51ed3aae7dc230179c Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 23 Mar 2020 16:40:43 -0600 Subject: [PATCH 02/64] [Maps] Default ES document layer scaling type to clusters and show scaling UI in the create wizard (#60668) * [Maps] show scaling panel in ES documents create wizard * minor fix * remove unused async state * update create editor to use ScalingForm * default geo field * ts lint errors * remove old dynamic filter behavior * update jest tests * eslint * remove indexCount route Co-authored-by: Elastic Machine --- .../maps/public/actions/map_actions.d.ts | 8 + .../layer_panel/view.d.ts | 14 + .../__snapshots__/scaling_form.test.tsx.snap | 205 ++++++++++++ .../update_source_editor.test.js.snap | 315 +----------------- .../es_search_source/create_source_editor.js | 189 +++++------ .../es_search_source/scaling_form.test.tsx | 47 +++ .../sources/es_search_source/scaling_form.tsx | 230 +++++++++++++ .../es_search_source/update_source_editor.js | 196 +---------- .../update_source_editor.test.js | 8 - x-pack/legacy/plugins/maps/server/routes.js | 20 -- x-pack/plugins/maps/common/constants.ts | 14 +- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 13 files changed, 636 insertions(+), 616 deletions(-) create mode 100644 x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts create mode 100644 x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap create mode 100644 x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx create mode 100644 x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts index 418f2880c1077..3a61d5affd861 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts @@ -5,6 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { LAYER_TYPE } from '../../common/constants'; import { DataMeta, MapFilters } from '../../common/data_request_descriptor_types'; export type SyncContext = { @@ -16,3 +17,10 @@ export type SyncContext = { registerCancelCallback(requestToken: symbol, callback: () => void): void; dataFilters: MapFilters; }; + +export function updateSourceProp( + layerId: string, + propName: string, + value: unknown, + newLayerType?: LAYER_TYPE +): void; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts new file mode 100644 index 0000000000000..6d1d076c723ad --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import { LAYER_TYPE } from '../../../common/constants'; + +export type OnSourceChangeArgs = { + propName: string; + value: unknown; + newLayerType?: LAYER_TYPE; +}; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap new file mode 100644 index 0000000000000..967225d6f0fdc --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should not render clusters option when clustering is not supported 1`] = ` + + +
+ +
+
+ + + + + + + +
+`; + +exports[`should render 1`] = ` + + +
+ +
+
+ + + + + + + +
+`; + +exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` + + +
+ +
+
+ + + + + + + + + + + +
+`; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap index c94f305773f35..0cb7f67fb9c92 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap @@ -91,253 +91,16 @@ exports[`should enable sort order select when sort field provided 1`] = ` size="s" /> - -
- -
-
- - - - - - - -
- - -`; - -exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` - - - -
- -
-
- - -
- - - -
- -
-
- - - - - - - -
- - - -
- -
-
- - - - - - - - - - - - - -
- -
- -
-
- - - - - - -
{ @@ -34,11 +27,26 @@ function getGeoFields(fields) { ); }); } + +function isGeoFieldAggregatable(indexPattern, geoFieldName) { + if (!indexPattern) { + return false; + } + + const geoField = indexPattern.fields.getByName(geoFieldName); + return geoField && geoField.aggregatable; +} + const RESET_INDEX_PATTERN_STATE = { indexPattern: undefined, - geoField: undefined, + geoFields: undefined, + + // ES search source descriptor state + geoFieldName: undefined, filterByMapBounds: DEFAULT_FILTER_BY_MAP_BOUNDS, - showFilterByBoundsSwitch: false, + scalingType: SCALING_TYPES.CLUSTERS, // turn on clusting by default + topHitsSplitField: undefined, + topHitsSize: 1, }; export class CreateSourceEditor extends Component { @@ -58,41 +66,28 @@ export class CreateSourceEditor extends Component { componentDidMount() { this._isMounted = true; - this.loadIndexPattern(this.state.indexPatternId); } - onIndexPatternSelect = indexPatternId => { + _onIndexPatternSelect = indexPatternId => { this.setState( { indexPatternId, }, - this.loadIndexPattern(indexPatternId) + this._loadIndexPattern(indexPatternId) ); }; - loadIndexPattern = indexPatternId => { + _loadIndexPattern = indexPatternId => { this.setState( { isLoadingIndexPattern: true, ...RESET_INDEX_PATTERN_STATE, }, - this.debouncedLoad.bind(null, indexPatternId) + this._debouncedLoad.bind(null, indexPatternId) ); }; - loadIndexDocCount = async indexPatternTitle => { - const http = getHttp(); - const { count } = await http.fetch(`../${GIS_API_PATH}/indexCount`, { - method: 'GET', - credentials: 'same-origin', - query: { - index: indexPatternTitle, - }, - }); - return count; - }; - - debouncedLoad = _.debounce(async indexPatternId => { + _debouncedLoad = _.debounce(async indexPatternId => { if (!indexPatternId || indexPatternId.length === 0) { return; } @@ -105,15 +100,6 @@ export class CreateSourceEditor extends Component { return; } - let indexHasSmallDocCount = false; - try { - const indexDocCount = await this.loadIndexDocCount(indexPattern.title); - indexHasSmallDocCount = indexDocCount <= DEFAULT_MAX_RESULT_WINDOW; - } catch (error) { - // retrieving index count is a nice to have and is not essential - // do not interrupt user flow if unable to retrieve count - } - if (!this._isMounted) { return; } @@ -124,43 +110,71 @@ export class CreateSourceEditor extends Component { return; } + const geoFields = getGeoFields(indexPattern.fields); this.setState({ isLoadingIndexPattern: false, indexPattern: indexPattern, - filterByMapBounds: !indexHasSmallDocCount, // Turn off filterByMapBounds when index contains a limited number of documents - showFilterByBoundsSwitch: indexHasSmallDocCount, + geoFields, }); - //make default selection - const geoFields = getGeoFields(indexPattern.fields); - if (geoFields[0]) { - this.onGeoFieldSelect(geoFields[0].name); + if (geoFields.length) { + // make default selection, prefer aggregatable field over the first available + const firstAggregatableGeoField = geoFields.find(geoField => { + return geoField.aggregatable; + }); + const defaultGeoFieldName = firstAggregatableGeoField + ? firstAggregatableGeoField + : geoFields[0]; + this._onGeoFieldSelect(defaultGeoFieldName.name); } }, 300); - onGeoFieldSelect = geoField => { + _onGeoFieldSelect = geoFieldName => { + // Respect previous scaling type selection unless newly selected geo field does not support clustering. + const scalingType = + this.state.scalingType === SCALING_TYPES.CLUSTERS && + !isGeoFieldAggregatable(this.state.indexPattern, geoFieldName) + ? SCALING_TYPES.LIMIT + : this.state.scalingType; this.setState( { - geoField, + geoFieldName, + scalingType, }, - this.previewLayer + this._previewLayer ); }; - onFilterByMapBoundsChange = event => { + _onScalingPropChange = ({ propName, value }) => { this.setState( { - filterByMapBounds: event.target.checked, + [propName]: value, }, - this.previewLayer + this._previewLayer ); }; - previewLayer = () => { - const { indexPatternId, geoField, filterByMapBounds } = this.state; + _previewLayer = () => { + const { + indexPatternId, + geoFieldName, + filterByMapBounds, + scalingType, + topHitsSplitField, + topHitsSize, + } = this.state; const sourceConfig = - indexPatternId && geoField ? { indexPatternId, geoField, filterByMapBounds } : null; + indexPatternId && geoFieldName + ? { + indexPatternId, + geoField: geoFieldName, + filterByMapBounds, + scalingType, + topHitsSplitField, + topHitsSize, + } + : null; this.props.onSourceConfigChange(sourceConfig); }; @@ -183,56 +197,35 @@ export class CreateSourceEditor extends Component { placeholder={i18n.translate('xpack.maps.source.esSearch.selectLabel', { defaultMessage: 'Select geo field', })} - value={this.state.geoField} - onChange={this.onGeoFieldSelect} - fields={ - this.state.indexPattern ? getGeoFields(this.state.indexPattern.fields) : undefined - } + value={this.state.geoFieldName} + onChange={this._onGeoFieldSelect} + fields={this.state.geoFields} /> ); } - _renderFilterByMapBounds() { - if (!this.state.showFilterByBoundsSwitch) { + _renderScalingPanel() { + if (!this.state.indexPattern || !this.state.geoFieldName) { return null; } return ( - -

- -

-

- -

-
- - - - + +
); } @@ -265,7 +258,7 @@ export class CreateSourceEditor extends Component { ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx new file mode 100644 index 0000000000000..03f29685891ec --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('../../../kibana_services', () => ({})); + +jest.mock('./load_index_settings', () => ({ + loadIndexSettings: async () => { + return { maxInnerResultWindow: 100, maxResultWindow: 10000 }; + }, +})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ScalingForm } from './scaling_form'; +import { SCALING_TYPES } from '../../../../common/constants'; + +const defaultProps = { + filterByMapBounds: true, + indexPatternId: 'myIndexPattern', + onChange: () => {}, + scalingType: SCALING_TYPES.LIMIT, + supportsClustering: true, + termFields: [], + topHitsSize: 1, +}; + +test('should render', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should not render clusters option when clustering is not supported', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render top hits form when scaling type is TOP_HITS', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx new file mode 100644 index 0000000000000..c5950f1132974 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, Component } from 'react'; +import { + EuiFormRow, + EuiSwitch, + EuiSwitchEvent, + EuiTitle, + EuiSpacer, + EuiHorizontalRule, + EuiRadioGroup, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-ignore +import { SingleFieldSelect } from '../../../components/single_field_select'; + +// @ts-ignore +import { indexPatternService } from '../../../kibana_services'; +// @ts-ignore +import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; +// @ts-ignore +import { ValidatedRange } from '../../../components/validated_range'; +import { + DEFAULT_MAX_INNER_RESULT_WINDOW, + DEFAULT_MAX_RESULT_WINDOW, + SCALING_TYPES, + LAYER_TYPE, +} from '../../../../common/constants'; +// @ts-ignore +import { loadIndexSettings } from './load_index_settings'; +import { IFieldType } from '../../../../../../../../src/plugins/data/public'; +import { OnSourceChangeArgs } from '../../../connected_components/layer_panel/view'; + +interface Props { + filterByMapBounds: boolean; + indexPatternId: string; + onChange: (args: OnSourceChangeArgs) => void; + scalingType: SCALING_TYPES; + supportsClustering: boolean; + termFields: IFieldType[]; + topHitsSplitField?: string; + topHitsSize: number; +} + +interface State { + maxInnerResultWindow: number; + maxResultWindow: number; +} + +export class ScalingForm extends Component { + state = { + maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, + maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, + }; + _isMounted = false; + + componentDidMount() { + this._isMounted = true; + this.loadIndexSettings(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async loadIndexSettings() { + try { + const indexPattern = await indexPatternService.get(this.props.indexPatternId); + const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); + if (this._isMounted) { + this.setState({ maxInnerResultWindow, maxResultWindow }); + } + } catch (err) { + return; + } + } + + _onScalingTypeChange = (optionId: string): void => { + const layerType = + optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; + this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); + }; + + _onFilterByMapBoundsChange = (event: EuiSwitchEvent) => { + this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); + }; + + _onTopHitsSplitFieldChange = (topHitsSplitField: string) => { + this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); + }; + + _onTopHitsSizeChange = (size: number) => { + this.props.onChange({ propName: 'topHitsSize', value: size }); + }; + + _renderTopHitsForm() { + let sizeSlider; + if (this.props.topHitsSplitField) { + sizeSlider = ( + + + + ); + } + + return ( + + + + + + {sizeSlider} + + ); + } + + render() { + const scalingOptions = [ + { + id: SCALING_TYPES.LIMIT, + label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { + defaultMessage: 'Limit results to {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }, + { + id: SCALING_TYPES.TOP_HITS, + label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { + defaultMessage: 'Show top hits per entity.', + }), + }, + ]; + if (this.props.supportsClustering) { + scalingOptions.push({ + id: SCALING_TYPES.CLUSTERS, + label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { + defaultMessage: 'Show clusters when results exceed {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }); + } + + let filterByBoundsSwitch; + if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { + filterByBoundsSwitch = ( + + + + ); + } + + let scalingForm = null; + if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { + scalingForm = ( + + + {this._renderTopHitsForm()} + + ); + } + + return ( + + +
+ +
+
+ + + + + + + + {filterByBoundsSwitch} + + {scalingForm} +
+ ); + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js index 4d1e32087ab8c..9c92ec5801e49 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js @@ -6,34 +6,18 @@ import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; -import { - EuiFormRow, - EuiSwitch, - EuiSelect, - EuiTitle, - EuiPanel, - EuiSpacer, - EuiHorizontalRule, - EuiRadioGroup, -} from '@elastic/eui'; +import { EuiFormRow, EuiSelect, EuiTitle, EuiPanel, EuiSpacer } from '@elastic/eui'; import { SingleFieldSelect } from '../../../components/single_field_select'; import { TooltipSelector } from '../../../components/tooltip_selector'; import { getIndexPatternService } from '../../../kibana_services'; import { i18n } from '@kbn/i18n'; import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; -import { ValidatedRange } from '../../../components/validated_range'; -import { - DEFAULT_MAX_INNER_RESULT_WINDOW, - DEFAULT_MAX_RESULT_WINDOW, - SORT_ORDER, - SCALING_TYPES, - LAYER_TYPE, -} from '../../../../common/constants'; +import { SORT_ORDER } from '../../../../common/constants'; import { ESDocField } from '../../fields/es_doc_field'; import { FormattedMessage } from '@kbn/i18n/react'; -import { loadIndexSettings } from './load_index_settings'; import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; +import { ScalingForm } from './scaling_form'; export class UpdateSourceEditor extends Component { static propTypes = { @@ -52,33 +36,18 @@ export class UpdateSourceEditor extends Component { sourceFields: null, termFields: null, sortFields: null, - maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, - maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, supportsClustering: false, }; componentDidMount() { this._isMounted = true; this.loadFields(); - this.loadIndexSettings(); } componentWillUnmount() { this._isMounted = false; } - async loadIndexSettings() { - try { - const indexPattern = await getIndexPatternService().get(this.props.indexPatternId); - const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); - if (this._isMounted) { - this.setState({ maxInnerResultWindow, maxResultWindow }); - } - } catch (err) { - return; - } - } - async loadFields() { let indexPattern; try { @@ -133,85 +102,14 @@ export class UpdateSourceEditor extends Component { this.props.onChange({ propName: 'tooltipProperties', value: propertyNames }); }; - _onScalingTypeChange = optionId => { - const layerType = - optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; - this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); - }; - - _onFilterByMapBoundsChange = event => { - this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); - }; - - onTopHitsSplitFieldChange = topHitsSplitField => { - this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); - }; - - onSortFieldChange = sortField => { + _onSortFieldChange = sortField => { this.props.onChange({ propName: 'sortField', value: sortField }); }; - onSortOrderChange = e => { + _onSortOrderChange = e => { this.props.onChange({ propName: 'sortOrder', value: e.target.value }); }; - onTopHitsSizeChange = size => { - this.props.onChange({ propName: 'topHitsSize', value: size }); - }; - - _renderTopHitsForm() { - let sizeSlider; - if (this.props.topHitsSplitField) { - sizeSlider = ( - - - - ); - } - - return ( - - - - - - {sizeSlider} - - ); - } - _renderTooltipsPanel() { return ( @@ -257,7 +155,7 @@ export class UpdateSourceEditor extends Component { defaultMessage: 'Select sort field', })} value={this.props.sortField} - onChange={this.onSortFieldChange} + onChange={this._onSortFieldChange} fields={this.state.sortFields} compressed /> @@ -286,7 +184,7 @@ export class UpdateSourceEditor extends Component { }, ]} value={this.props.sortOrder} - onChange={this.onSortOrderChange} + onChange={this._onSortOrderChange} compressed /> @@ -295,78 +193,18 @@ export class UpdateSourceEditor extends Component { } _renderScalingPanel() { - const scalingOptions = [ - { - id: SCALING_TYPES.LIMIT, - label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { - defaultMessage: 'Limit results to {maxResultWindow}.', - values: { maxResultWindow: this.state.maxResultWindow }, - }), - }, - { - id: SCALING_TYPES.TOP_HITS, - label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { - defaultMessage: 'Show top hits per entity.', - }), - }, - ]; - if (this.state.supportsClustering) { - scalingOptions.push({ - id: SCALING_TYPES.CLUSTERS, - label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { - defaultMessage: 'Show clusters when results exceed {maxResultWindow}.', - values: { maxResultWindow: this.state.maxResultWindow }, - }), - }); - } - - let filterByBoundsSwitch; - if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { - filterByBoundsSwitch = ( - - - - ); - } - - let scalingForm = null; - if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { - scalingForm = ( - - - {this._renderTopHitsForm()} - - ); - } - return ( - -
- -
-
- - - - - - - - {filterByBoundsSwitch} - - {scalingForm} +
); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js index e8a845c4b1669..65a91ce03994a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js @@ -40,11 +40,3 @@ test('should enable sort order select when sort field provided', async () => { expect(component).toMatchSnapshot(); }); - -test('should render top hits form when scaling type is TOP_HITS', async () => { - const component = shallow( - - ); - - expect(component).toMatchSnapshot(); -}); diff --git a/x-pack/legacy/plugins/maps/server/routes.js b/x-pack/legacy/plugins/maps/server/routes.js index 7ca659148449f..6aacfdc41aeea 100644 --- a/x-pack/legacy/plugins/maps/server/routes.js +++ b/x-pack/legacy/plugins/maps/server/routes.js @@ -409,26 +409,6 @@ export function initRoutes(server, licenseUid) { }, }); - server.route({ - method: 'GET', - path: `${ROOT}/indexCount`, - handler: async (request, h) => { - const { server, query } = request; - - if (!query.index) { - return h.response().code(400); - } - - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - try { - const { count } = await callWithRequest(request, 'count', { index: query.index }); - return { count }; - } catch (error) { - return h.response().code(400); - } - }, - }); - server.route({ method: 'GET', path: `/${INDEX_SETTINGS_API_PATH}`, diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index fecf8db0e85de..12b03f0386304 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -43,13 +43,13 @@ export function createMapPath(id: string) { return `${MAP_BASE_URL}/${id}`; } -export const LAYER_TYPE = { - TILE: 'TILE', - VECTOR: 'VECTOR', - VECTOR_TILE: 'VECTOR_TILE', - HEATMAP: 'HEATMAP', - BLENDED_VECTOR: 'BLENDED_VECTOR', -}; +export enum LAYER_TYPE { + TILE = 'TILE', + VECTOR = 'VECTOR', + VECTOR_TILE = 'VECTOR_TILE', + HEATMAP = 'HEATMAP', + BLENDED_VECTOR = 'BLENDED_VECTOR', +} export enum SORT_ORDER { ASC = 'asc', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ce78847a8e8b3..e8d93ba6d3200 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7170,9 +7170,6 @@ "xpack.maps.source.esGridDescription": "それぞれのグリッド付きセルのメトリックでグリッドにグループ分けされた地理空間データです。", "xpack.maps.source.esGridTitle": "グリッド集約", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "検索への応答を geoJson 機能コレクションに変換できません。エラー: {errorMsg}", - "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "インデックス「{indexPatternTitle}」はドキュメント数が少なく、ダイナミックフィルターが必要ありません。", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "ダイナミックデータフィルターは無効です", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTurnOnMsg": "ドキュメント数が増えると思われる場合はダイナミックフィルターをオンにしてください。", "xpack.maps.source.esSearch.extentFilterLabel": "マップの表示範囲でデータを動的にフィルタリング", "xpack.maps.source.esSearch.geofieldLabel": "地理空間フィールド", "xpack.maps.source.esSearch.geoFieldLabel": "地理空間フィールド", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d5d0a2f9e7aff..cfab424935c6d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7170,9 +7170,6 @@ "xpack.maps.source.esGridDescription": "地理空间数据在网格中进行分组,每个网格单元格都具有指标", "xpack.maps.source.esGridTitle": "网格聚合", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "无法将搜索响应转换成 geoJson 功能集合,错误:{errorMsg}", - "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "索引“{indexPatternTitle}”具有很少数量的文档,不需要动态筛选。", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "动态数据筛选已禁用", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTurnOnMsg": "如果预期文档数量会增加,请打开动态筛选。", "xpack.maps.source.esSearch.extentFilterLabel": "在可见地图区域中动态筛留数据", "xpack.maps.source.esSearch.geofieldLabel": "地理空间字段", "xpack.maps.source.esSearch.geoFieldLabel": "地理空间字段", From 72bc0eae3268b1dfb7c314e80438c7a88f61352e Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Mon, 23 Mar 2020 19:02:28 -0400 Subject: [PATCH 03/64] [Alerting] allow email action to not require auth (#60839) resolves https://github.com/elastic/kibana/issues/57143 Currently, the built-in email action requires user/password properties to be set in it's secrets parameters. This PR changes that requirement, so they are no longer required. --- .../server/builtin_action_types/email.test.ts | 14 ++-- .../server/builtin_action_types/email.ts | 25 +++--- .../builtin_action_types/email.test.tsx | 81 +++++++++++++++++++ .../components/builtin_action_types/email.tsx | 44 +++++----- .../components/builtin_action_types/types.ts | 4 +- .../actions/builtin_action_types/email.ts | 56 +++++++++++++ 6 files changed, 181 insertions(+), 43 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 0bd3992de30e6..469df4fd86e2c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -184,12 +184,14 @@ describe('secrets validation', () => { expect(validateSecrets(actionType, secrets)).toEqual(secrets); }); - test('secrets validation fails when secrets is not valid', () => { - expect(() => { - validateSecrets(actionType, {}); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [user]: expected value of type [string] but got [undefined]"` - ); + test('secrets validation succeeds when secrets props are null/undefined', () => { + const secrets: Record = { + user: null, + password: null, + }; + expect(validateSecrets(actionType, {})).toEqual(secrets); + expect(validateSecrets(actionType, { user: null })).toEqual(secrets); + expect(validateSecrets(actionType, { password: null })).toEqual(secrets); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index 16e0168a7deb9..7992920fdfcb4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -10,7 +10,6 @@ import { schema, TypeOf } from '@kbn/config-schema'; import nodemailerGetService from 'nodemailer/lib/well-known'; import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email'; -import { nullableType } from './lib/nullable'; import { portSchema } from './lib/schemas'; import { Logger } from '../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; @@ -20,10 +19,10 @@ import { ActionsConfigurationUtilities } from '../actions_config'; export type ActionTypeConfigType = TypeOf; const ConfigSchemaProps = { - service: nullableType(schema.string()), - host: nullableType(schema.string()), - port: nullableType(portSchema()), - secure: nullableType(schema.boolean()), + service: schema.nullable(schema.string()), + host: schema.nullable(schema.string()), + port: schema.nullable(portSchema()), + secure: schema.nullable(schema.boolean()), from: schema.string(), }; @@ -75,8 +74,8 @@ function validateConfig( export type ActionTypeSecretsType = TypeOf; const SecretsSchema = schema.object({ - user: schema.string(), - password: schema.string(), + user: schema.nullable(schema.string()), + password: schema.nullable(schema.string()), }); // params definition @@ -144,10 +143,14 @@ async function executor( const secrets = execOptions.secrets as ActionTypeSecretsType; const params = execOptions.params as ActionParamsType; - const transport: any = { - user: secrets.user, - password: secrets.password, - }; + const transport: any = {}; + + if (secrets.user != null) { + transport.user = secrets.user; + } + if (secrets.password != null) { + transport.password = secrets.password; + } if (config.service !== null) { transport.service = config.service; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx index 49a611167cf16..a7d479f922ed1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx @@ -58,6 +58,33 @@ describe('connector validation', () => { }); }); + test('connector validation succeeds when connector config is valid with empty user/password', () => { + const actionConnector = { + secrets: { + user: null, + password: null, + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: 2323, + host: 'localhost', + test: 'test', + }, + } as EmailActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + port: [], + host: [], + user: [], + password: [], + }, + }); + }); test('connector validation fails when connector config is not valid', () => { const actionConnector = { secrets: { @@ -82,6 +109,60 @@ describe('connector validation', () => { }, }); }); + test('connector validation fails when user specified but not password', () => { + const actionConnector = { + secrets: { + user: 'user', + password: null, + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: 2323, + host: 'localhost', + test: 'test', + }, + } as EmailActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + port: [], + host: [], + user: [], + password: ['Password is required when username is used.'], + }, + }); + }); + test('connector validation fails when password specified but not user', () => { + const actionConnector = { + secrets: { + user: null, + password: 'password', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: 2323, + host: 'localhost', + test: 'test', + }, + } as EmailActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + port: [], + host: [], + user: ['Username is required when password is used.'], + password: [], + }, + }); + }); }); describe('action params validation', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx index 6c994051ec980..f17180ee74e56 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx @@ -97,22 +97,22 @@ export function getActionType(): ActionTypeModel { ) ); } - if (!action.secrets.user) { - errors.user.push( + if (action.secrets.user && !action.secrets.password) { + errors.password.push( i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', { - defaultMessage: 'Username is required.', + defaultMessage: 'Password is required when username is used.', } ) ); } - if (!action.secrets.password) { - errors.password.push( + if (!action.secrets.user && action.secrets.password) { + errors.user.push( i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', { - defaultMessage: 'Password is required.', + defaultMessage: 'Username is required when password is used.', } ) ); @@ -303,7 +303,7 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && user !== undefined} + isInvalid={errors.user.length > 0} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', { @@ -313,17 +313,12 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && user !== undefined} + isInvalid={errors.user.length > 0} name="user" value={user || ''} data-test-subj="emailUserInput" onChange={e => { - editActionSecrets('user', e.target.value); - }} - onBlur={() => { - if (!user) { - editActionSecrets('user', ''); - } + editActionSecrets('user', nullableString(e.target.value)); }} /> @@ -333,7 +328,7 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && password !== undefined} + isInvalid={errors.password.length > 0} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', { @@ -343,17 +338,12 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && password !== undefined} + isInvalid={errors.password.length > 0} name="password" value={password || ''} data-test-subj="emailPasswordInput" onChange={e => { - editActionSecrets('password', e.target.value); - }} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } + editActionSecrets('password', nullableString(e.target.value)); }} /> @@ -624,3 +614,9 @@ const EmailParamsFields: React.FunctionComponent ); }; + +// if the string == null or is empty, return null, else return string +function nullableString(str: string | null | undefined) { + if (str == null || str.trim() === '') return null; + return str; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts index c0ddd6791e90e..2e0576d933f90 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts @@ -72,8 +72,8 @@ interface EmailConfig { } interface EmailSecrets { - user: string; - password: string; + user: string | null; + password: string | null; } export interface EmailActionConnector extends ActionConnector { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts index de856492e12fc..e228f6c1f81c6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts @@ -227,5 +227,61 @@ export default function emailTest({ getService }: FtrProviderContext) { .expect(200); expect(typeof createdAction.id).to.be('string'); }); + + it('should handle an email action with no auth', async () => { + const { body: createdAction } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An email action with no auth', + actionTypeId: '.email', + config: { + service: '__json', + from: 'jim@example.com', + }, + }) + .expect(200); + + await supertest + .post(`/api/action/${createdAction.id}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + to: ['kibana-action-test@elastic.co'], + subject: 'email-subject', + message: 'email-message', + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body.data.message.messageId).to.be.a('string'); + expect(resp.body.data.messageId).to.be.a('string'); + + delete resp.body.data.message.messageId; + delete resp.body.data.messageId; + + expect(resp.body.data).to.eql({ + envelope: { + from: 'jim@example.com', + to: ['kibana-action-test@elastic.co'], + }, + message: { + from: { address: 'jim@example.com', name: '' }, + to: [ + { + address: 'kibana-action-test@elastic.co', + name: '', + }, + ], + cc: null, + bcc: null, + subject: 'email-subject', + html: '

email-message

\n', + text: 'email-message', + headers: {}, + }, + }); + }); + }); }); } From 5755b2ac522483bd71ad0e1b31459338ff69cf93 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 23 Mar 2020 16:02:44 -0700 Subject: [PATCH 04/64] [Reporting/New Platform Migration] Use a new config service on server-side (#55882) * [Reporting/New Platform Migration] Use a new config service on server-side * unit test for createConfig * use promise.all and remove outdated comment * design feedback to avoid handling the entire config getter Co-authored-by: Elastic Machine --- .../__snapshots__/index.test.js.snap | 383 ------------------ x-pack/legacy/plugins/reporting/config.ts | 182 --------- .../execute_job/decrypt_job_headers.test.ts | 22 +- .../common/execute_job/decrypt_job_headers.ts | 8 +- .../get_conditional_headers.test.ts | 173 ++------ .../execute_job/get_conditional_headers.ts | 20 +- .../execute_job/get_custom_logo.test.ts | 14 +- .../common/execute_job/get_custom_logo.ts | 11 +- .../common/execute_job/get_full_urls.test.ts | 80 ++-- .../common/execute_job/get_full_urls.ts | 22 +- .../common/layouts/create_layout.ts | 7 +- .../common/layouts/print_layout.ts | 9 +- .../lib/screenshots/get_number_of_items.ts | 7 +- .../common/lib/screenshots/observable.test.ts | 19 +- .../common/lib/screenshots/observable.ts | 18 +- .../common/lib/screenshots/open_url.ts | 11 +- .../common/lib/screenshots/types.ts | 2 +- .../common/lib/screenshots/wait_for_render.ts | 4 +- .../screenshots/wait_for_visualizations.ts | 7 +- .../export_types/csv/server/create_job.ts | 6 +- .../csv/server/execute_job.test.js | 344 +++------------- .../export_types/csv/server/execute_job.ts | 30 +- .../csv/server/lib/hit_iterator.test.ts | 3 +- .../csv/server/lib/hit_iterator.ts | 5 +- .../reporting/export_types/csv/types.d.ts | 5 +- .../server/create_job/create_job.ts | 19 +- .../server/execute_job.ts | 23 +- .../server/lib/generate_csv.ts | 16 +- .../server/lib/generate_csv_search.ts | 20 +- .../csv_from_savedobject/types.d.ts | 5 +- .../png/server/create_job/index.ts | 6 +- .../png/server/execute_job/index.test.js | 94 ++--- .../png/server/execute_job/index.ts | 23 +- .../png/server/lib/generate_png.ts | 7 +- .../printable_pdf/server/create_job/index.ts | 6 +- .../server/execute_job/index.test.js | 80 ++-- .../printable_pdf/server/execute_job/index.ts | 25 +- .../printable_pdf/server/lib/generate_pdf.ts | 9 +- .../export_types/printable_pdf/types.d.ts | 2 +- x-pack/legacy/plugins/reporting/index.test.js | 34 -- x-pack/legacy/plugins/reporting/index.ts | 16 +- .../plugins/reporting/log_configuration.ts | 23 +- .../browsers/chromium/driver_factory/args.ts | 7 +- .../browsers/chromium/driver_factory/index.ts | 19 +- .../server/browsers/chromium/index.ts | 5 +- .../browsers/create_browser_driver_factory.ts | 22 +- .../browsers/download/ensure_downloaded.ts | 13 +- .../server/browsers/network_policy.ts | 9 +- .../reporting/server/browsers/types.d.ts | 2 - .../plugins/reporting/server/config/config.js | 21 - .../legacy/plugins/reporting/server/core.ts | 72 +++- .../legacy/plugins/reporting/server/index.ts | 2 +- .../legacy/plugins/reporting/server/legacy.ts | 73 +++- .../reporting/server/lib/create_queue.ts | 20 +- .../server/lib/create_worker.test.ts | 39 +- .../reporting/server/lib/create_worker.ts | 24 +- .../plugins/reporting/server/lib/crypto.ts | 7 +- .../reporting/server/lib/enqueue_job.ts | 31 +- .../lib/esqueue/helpers/index_timestamp.js | 1 + .../plugins/reporting/server/lib/get_user.ts | 4 +- .../plugins/reporting/server/lib/index.ts | 9 +- .../reporting/server/lib/jobs_query.ts | 10 +- .../reporting/server/lib/once_per_server.ts | 43 -- .../__tests__/validate_encryption_key.js | 34 -- .../__tests__/validate_server_host.ts | 30 -- .../reporting/server/lib/validate/index.ts | 13 +- .../server/lib/validate/validate_browser.ts | 4 +- .../lib/validate/validate_encryption_key.ts | 31 -- .../validate_max_content_length.test.js | 16 +- .../validate/validate_max_content_length.ts | 14 +- .../lib/validate/validate_server_host.ts | 27 -- .../legacy/plugins/reporting/server/plugin.ts | 24 +- .../server/routes/generate_from_jobparams.ts | 5 +- .../routes/generate_from_savedobject.ts | 5 +- .../generate_from_savedobject_immediate.ts | 18 +- .../server/routes/generation.test.ts | 11 +- .../reporting/server/routes/generation.ts | 15 +- .../plugins/reporting/server/routes/index.ts | 7 +- .../reporting/server/routes/jobs.test.js | 46 ++- .../plugins/reporting/server/routes/jobs.ts | 15 +- .../lib/authorized_user_pre_routing.test.js | 131 +++--- .../routes/lib/authorized_user_pre_routing.ts | 16 +- .../server/routes/lib/get_document_payload.ts | 31 +- .../server/routes/lib/job_response_handler.ts | 15 +- .../lib/reporting_feature_pre_routing.ts | 8 +- .../routes/lib/route_config_factories.ts | 28 +- .../plugins/reporting/server/types.d.ts | 11 +- .../server/usage/get_reporting_usage.ts | 28 +- .../usage/reporting_usage_collector.test.js | 152 +++---- .../server/usage/reporting_usage_collector.ts | 23 +- .../create_mock_browserdriverfactory.ts | 45 +- .../create_mock_layoutinstance.ts | 8 +- .../create_mock_reportingplugin.ts | 22 +- .../test_helpers/create_mock_server.ts | 34 +- x-pack/legacy/plugins/reporting/types.d.ts | 62 +-- x-pack/plugins/reporting/config.ts | 10 - x-pack/plugins/reporting/kibana.json | 6 +- .../reporting/server/config/index.test.ts | 122 ++++++ .../plugins/reporting/server/config/index.ts | 85 ++++ .../reporting/server/config/schema.test.ts | 103 +++++ .../plugins/reporting/server/config/schema.ts | 174 ++++++++ x-pack/plugins/reporting/server/index.ts | 14 + x-pack/plugins/reporting/server/plugin.ts | 38 ++ 103 files changed, 1522 insertions(+), 2192 deletions(-) delete mode 100644 x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap delete mode 100644 x-pack/legacy/plugins/reporting/config.ts delete mode 100644 x-pack/legacy/plugins/reporting/index.test.js delete mode 100644 x-pack/legacy/plugins/reporting/server/config/config.js delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts delete mode 100644 x-pack/plugins/reporting/config.ts create mode 100644 x-pack/plugins/reporting/server/config/index.test.ts create mode 100644 x-pack/plugins/reporting/server/config/index.ts create mode 100644 x-pack/plugins/reporting/server/config/schema.test.ts create mode 100644 x-pack/plugins/reporting/server/config/schema.ts create mode 100644 x-pack/plugins/reporting/server/index.ts create mode 100644 x-pack/plugins/reporting/server/plugin.ts diff --git a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap deleted file mode 100644 index 757677f1d4f82..0000000000000 --- a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap +++ /dev/null @@ -1,383 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`config schema with context {"dev":false,"dist":false} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": true, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 1, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":false,"dist":true} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": false, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 3, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":true,"dist":false} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": true, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 1, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":true,"dist":true} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": false, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 3, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts deleted file mode 100644 index 211fa70301bbf..0000000000000 --- a/x-pack/legacy/plugins/reporting/config.ts +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BROWSER_TYPE } from './common/constants'; -// @ts-ignore untyped module -import { config as appConfig } from './server/config/config'; -import { getDefaultChromiumSandboxDisabled } from './server/browsers'; - -export async function config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - kibanaServer: Joi.object({ - protocol: Joi.string().valid(['http', 'https']), - hostname: Joi.string().invalid('0'), - port: Joi.number().integer(), - }).default(), - queue: Joi.object({ - indexInterval: Joi.string().default('week'), - pollEnabled: Joi.boolean().default(true), - pollInterval: Joi.number() - .integer() - .default(3000), - pollIntervalErrorMultiplier: Joi.number() - .integer() - .default(10), - timeout: Joi.number() - .integer() - .default(120000), - }).default(), - capture: Joi.object({ - timeouts: Joi.object({ - openUrl: Joi.number() - .integer() - .default(30000), - waitForElements: Joi.number() - .integer() - .default(30000), - renderComplete: Joi.number() - .integer() - .default(30000), - }).default(), - networkPolicy: Joi.object({ - enabled: Joi.boolean().default(true), - rules: Joi.array() - .items( - Joi.object({ - allow: Joi.boolean().required(), - protocol: Joi.string(), - host: Joi.string(), - }) - ) - .default([ - { allow: true, protocol: 'http:' }, - { allow: true, protocol: 'https:' }, - { allow: true, protocol: 'ws:' }, - { allow: true, protocol: 'wss:' }, - { allow: true, protocol: 'data:' }, - { allow: false }, // Default action is to deny! - ]), - }).default(), - zoom: Joi.number() - .integer() - .default(2), - viewport: Joi.object({ - width: Joi.number() - .integer() - .default(1950), - height: Joi.number() - .integer() - .default(1200), - }).default(), - timeout: Joi.number() - .integer() - .default(20000), // deprecated - loadDelay: Joi.number() - .integer() - .default(3000), - settleTime: Joi.number() - .integer() - .default(1000), // deprecated - concurrency: Joi.number() - .integer() - .default(appConfig.concurrency), // deprecated - browser: Joi.object({ - type: Joi.any() - .valid(BROWSER_TYPE) - .default(BROWSER_TYPE), - autoDownload: Joi.boolean().when('$dist', { - is: true, - then: Joi.default(false), - otherwise: Joi.default(true), - }), - chromium: Joi.object({ - inspect: Joi.boolean() - .when('$dev', { - is: false, - then: Joi.valid(false), - else: Joi.default(false), - }) - .default(), - disableSandbox: Joi.boolean().default(await getDefaultChromiumSandboxDisabled()), - proxy: Joi.object({ - enabled: Joi.boolean().default(false), - server: Joi.string() - .uri({ scheme: ['http', 'https'] }) - .when('enabled', { - is: Joi.valid(false), - then: Joi.valid(null), - else: Joi.required(), - }), - bypass: Joi.array() - .items(Joi.string().regex(/^[^\s]+$/)) - .when('enabled', { - is: Joi.valid(false), - then: Joi.valid(null), - else: Joi.default([]), - }), - }).default(), - maxScreenshotDimension: Joi.number() - .integer() - .default(1950), - }).default(), - }).default(), - maxAttempts: Joi.number() - .integer() - .greater(0) - .when('$dist', { - is: true, - then: Joi.default(3), - otherwise: Joi.default(1), - }) - .default(), - }).default(), - csv: Joi.object({ - checkForFormulas: Joi.boolean().default(true), - enablePanelActionDownload: Joi.boolean().default(true), - maxSizeBytes: Joi.number() - .integer() - .default(1024 * 1024 * 10), // bytes in a kB * kB in a mB * 10 - scroll: Joi.object({ - duration: Joi.string() - .regex(/^[0-9]+(d|h|m|s|ms|micros|nanos)$/, { name: 'DurationString' }) - .default('30s'), - size: Joi.number() - .integer() - .default(500), - }).default(), - }).default(), - encryptionKey: Joi.when(Joi.ref('$dist'), { - is: true, - then: Joi.string(), - otherwise: Joi.string().default('a'.repeat(32)), - }), - roles: Joi.object({ - allow: Joi.array() - .items(Joi.string()) - .default(['reporting_user']), - }).default(), - index: Joi.string().default('.reporting'), - poll: Joi.object({ - jobCompletionNotifier: Joi.object({ - interval: Joi.number() - .integer() - .default(10000), - intervalErrorMultiplier: Joi.number() - .integer() - .default(5), - }).default(), - jobsRefresh: Joi.object({ - interval: Joi.number() - .integer() - .default(5000), - intervalErrorMultiplier: Joi.number() - .integer() - .default(5), - }).default(), - }).default(), - }).default(); -} diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts index 468caf93ec5dd..9085fb3cbc876 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts @@ -5,33 +5,27 @@ */ import { cryptoFactory } from '../../../server/lib/crypto'; -import { createMockServer } from '../../../test_helpers'; import { Logger } from '../../../types'; import { decryptJobHeaders } from './decrypt_job_headers'; -let mockServer: any; -beforeEach(() => { - mockServer = createMockServer(''); -}); - -const encryptHeaders = async (headers: Record) => { - const crypto = cryptoFactory(mockServer); +const encryptHeaders = async (encryptionKey: string, headers: Record) => { + const crypto = cryptoFactory(encryptionKey); return await crypto.encrypt(headers); }; describe('headers', () => { test(`fails if it can't decrypt headers`, async () => { - await expect( + const getDecryptedHeaders = () => decryptJobHeaders({ + encryptionKey: 'abcsecretsauce', job: { headers: 'Q53+9A+zf+Xe+ceR/uB/aR/Sw/8e+M+qR+WiG+8z+EY+mo+HiU/zQL+Xn', }, logger: ({ error: jest.fn(), } as unknown) as Logger, - server: mockServer, - }) - ).rejects.toMatchInlineSnapshot( + }); + await expect(getDecryptedHeaders()).rejects.toMatchInlineSnapshot( `[Error: Failed to decrypt report job data. Please ensure that xpack.reporting.encryptionKey is set and re-generate this report. Error: Invalid IV length]` ); }); @@ -42,15 +36,15 @@ describe('headers', () => { baz: 'quix', }; - const encryptedHeaders = await encryptHeaders(headers); + const encryptedHeaders = await encryptHeaders('abcsecretsauce', headers); const decryptedHeaders = await decryptJobHeaders({ + encryptionKey: 'abcsecretsauce', job: { title: 'cool-job-bro', type: 'csv', headers: encryptedHeaders, }, logger: {} as Logger, - server: mockServer, }); expect(decryptedHeaders).toEqual(headers); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts index 436b2c2dab1ad..6f415d7ee5ea9 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { cryptoFactory } from '../../../server/lib/crypto'; -import { CryptoFactory, ServerFacade, Logger } from '../../../types'; +import { CryptoFactory, Logger } from '../../../types'; interface HasEncryptedHeaders { headers?: string; @@ -17,15 +17,15 @@ export const decryptJobHeaders = async < JobParamsType, JobDocPayloadType extends HasEncryptedHeaders >({ - server, + encryptionKey, job, logger, }: { - server: ServerFacade; + encryptionKey?: string; job: JobDocPayloadType; logger: Logger; }): Promise> => { - const crypto: CryptoFactory = cryptoFactory(server); + const crypto: CryptoFactory = cryptoFactory(encryptionKey); try { const decryptedHeaders: Record = await crypto.decrypt(job.headers); return decryptedHeaders; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts index eedb742ad7597..09527621fa49f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts @@ -4,27 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createMockReportingCore, createMockServer } from '../../../test_helpers'; -import { ReportingCore } from '../../../server'; +import sinon from 'sinon'; +import { createMockReportingCore } from '../../../test_helpers'; +import { ReportingConfig, ReportingCore } from '../../../server/types'; import { JobDocPayload } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; +let mockConfig: ReportingConfig; let mockReportingPlugin: ReportingCore; -let mockServer: any; + +const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, +}); + beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); - mockServer = createMockServer(''); + + const mockConfigGet = sinon + .stub() + .withArgs('kibanaServer', 'hostname') + .returns('custom-hostname'); + mockConfig = getMockConfig(mockConfigGet); }); describe('conditions', () => { test(`uses hostname from reporting config if set`, async () => { - const settings: any = { - 'xpack.reporting.kibanaServer.hostname': 'custom-hostname', - }; - - mockServer = createMockServer({ settings }); - const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -33,121 +39,20 @@ describe('conditions', () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); expect(conditionalHeaders.conditions.hostname).toEqual( - mockServer.config().get('xpack.reporting.kibanaServer.hostname') + mockConfig.get('kibanaServer', 'hostname') ); - }); - - test(`uses hostname from server.config if reporting config not set`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.hostname).toEqual(mockServer.config().get('server.host')); - }); - - test(`uses port from reporting config if set`, async () => { - const settings = { - 'xpack.reporting.kibanaServer.port': 443, - }; - - mockServer = createMockServer({ settings }); - - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.port).toEqual( - mockServer.config().get('xpack.reporting.kibanaServer.port') + expect(conditionalHeaders.conditions.port).toEqual(mockConfig.get('kibanaServer', 'port')); + expect(conditionalHeaders.conditions.protocol).toEqual( + mockConfig.get('kibanaServer', 'protocol') ); - }); - - test(`uses port from server if reporting config not set`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.port).toEqual(mockServer.config().get('server.port')); - }); - - test(`uses basePath from server config`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - expect(conditionalHeaders.conditions.basePath).toEqual( - mockServer.config().get('server.basePath') + mockConfig.kbnConfig.get('server', 'basePath') ); }); - - test(`uses protocol from reporting config if set`, async () => { - const settings = { - 'xpack.reporting.kibanaServer.protocol': 'https', - }; - - mockServer = createMockServer({ settings }); - - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.protocol).toEqual( - mockServer.config().get('xpack.reporting.kibanaServer.protocol') - ); - }); - - test(`uses protocol from server.info`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.protocol).toEqual(mockServer.info.protocol); - }); }); test('uses basePath from job when creating saved object service', async () => { @@ -161,14 +66,14 @@ test('uses basePath from job when creating saved object service', async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); const jobBasePath = '/sbp/s/marketing'; await getCustomLogo({ reporting: mockReportingPlugin, job: { basePath: jobBasePath } as JobDocPayloadPDF, conditionalHeaders, - server: mockServer, + config: mockConfig, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -179,6 +84,11 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const mockGetSavedObjectsClient = jest.fn(); mockReportingPlugin.getSavedObjectsClient = mockGetSavedObjectsClient; + const mockConfigGet = sinon.stub(); + mockConfigGet.withArgs('kibanaServer', 'hostname').returns('localhost'); + mockConfigGet.withArgs('server', 'basePath').returns('/sbp'); + mockConfig = getMockConfig(mockConfigGet); + const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -186,14 +96,14 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); await getCustomLogo({ reporting: mockReportingPlugin, job: {} as JobDocPayloadPDF, conditionalHeaders, - server: mockServer, + config: mockConfig, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -225,19 +135,26 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav describe('config formatting', () => { test(`lowercases server.host`, async () => { - mockServer = createMockServer({ settings: { 'server.host': 'COOL-HOSTNAME' } }); + const mockConfigGet = sinon + .stub() + .withArgs('server', 'host') + .returns('COOL-HOSTNAME'); + mockConfig = getMockConfig(mockConfigGet); + const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: {}, - server: mockServer, + config: mockConfig, }); expect(conditionalHeaders.conditions.hostname).toEqual('cool-hostname'); }); - test(`lowercases xpack.reporting.kibanaServer.hostname`, async () => { - mockServer = createMockServer({ - settings: { 'xpack.reporting.kibanaServer.hostname': 'GREAT-HOSTNAME' }, - }); + test(`lowercases kibanaServer.hostname`, async () => { + const mockConfigGet = sinon + .stub() + .withArgs('kibanaServer', 'hostname') + .returns('GREAT-HOSTNAME'); + mockConfig = getMockConfig(mockConfigGet); const conditionalHeaders = await getConditionalHeaders({ job: { title: 'cool-job-bro', @@ -249,7 +166,7 @@ describe('config formatting', () => { }, }, filteredHeaders: {}, - server: mockServer, + config: mockConfig, }); expect(conditionalHeaders.conditions.hostname).toEqual('great-hostname'); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts index 975060a8052f0..bd7999d697ca9 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts @@ -3,29 +3,31 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ConditionalHeaders, ServerFacade } from '../../../types'; + +import { ReportingConfig } from '../../../server/types'; +import { ConditionalHeaders } from '../../../types'; export const getConditionalHeaders = ({ - server, + config, job, filteredHeaders, }: { - server: ServerFacade; + config: ReportingConfig; job: JobDocPayloadType; filteredHeaders: Record; }) => { - const config = server.config(); + const { kbnConfig } = config; const [hostname, port, basePath, protocol] = [ - config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), - config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), - config.get('server.basePath'), - config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), ] as [string, number, string, string]; const conditionalHeaders: ConditionalHeaders = { headers: filteredHeaders, conditions: { - hostname: hostname.toLowerCase(), + hostname: hostname ? hostname.toLowerCase() : hostname, port, basePath, protocol, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts index fa53f474dfba7..7c4c889e3e14f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts @@ -5,16 +5,18 @@ */ import { ReportingCore } from '../../../server'; -import { createMockReportingCore, createMockServer } from '../../../test_helpers'; -import { ServerFacade } from '../../../types'; +import { createMockReportingCore } from '../../../test_helpers'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; +const mockConfigGet = jest.fn().mockImplementation((key: string) => { + return 'localhost'; +}); +const mockConfig = { get: mockConfigGet, kbnConfig: { get: mockConfigGet } }; + let mockReportingPlugin: ReportingCore; -let mockServer: ServerFacade; beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); - mockServer = createMockServer(''); }); test(`gets logo from uiSettings`, async () => { @@ -37,14 +39,14 @@ test(`gets logo from uiSettings`, async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayloadPDF, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); const { logo } = await getCustomLogo({ reporting: mockReportingPlugin, + config: mockConfig, job: {} as JobDocPayloadPDF, conditionalHeaders, - server: mockServer, }); expect(mockGet).toBeCalledWith('xpackReporting:customPdfLogo'); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts index 7af5edab41ab7..a13f992e7867c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts @@ -5,23 +5,22 @@ */ import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants'; -import { ReportingCore } from '../../../server'; -import { ConditionalHeaders, ServerFacade } from '../../../types'; +import { ReportingConfig, ReportingCore } from '../../../server/types'; +import { ConditionalHeaders } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; // Logo is PDF only export const getCustomLogo = async ({ reporting, - server, + config, job, conditionalHeaders, }: { reporting: ReportingCore; - server: ServerFacade; + config: ReportingConfig; job: JobDocPayloadPDF; conditionalHeaders: ConditionalHeaders; }) => { - const serverBasePath: string = server.config().get('server.basePath'); - + const serverBasePath: string = config.kbnConfig.get('server', 'basePath'); const fakeRequest: any = { headers: conditionalHeaders.headers, // This is used by the spaces SavedObjectClientWrapper to determine the existing space. diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts index 27e772195f726..5f55617724ff6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts @@ -4,29 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createMockServer } from '../../../test_helpers'; -import { ServerFacade } from '../../../types'; +import { ReportingConfig } from '../../../server'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getFullUrls } from './get_full_urls'; interface FullUrlsOpts { job: JobDocPayloadPNG & JobDocPayloadPDF; - server: ServerFacade; - conditionalHeaders: any; + config: ReportingConfig; } -let mockServer: any; +let mockConfig: ReportingConfig; +const getMockConfig = (mockConfigGet: jest.Mock) => { + return { + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, + }; +}; + beforeEach(() => { - mockServer = createMockServer(''); + const reportingConfig: Record = { + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + 'server.basePath': '/sbp', + }; + const mockConfigGet = jest.fn().mockImplementation((...keys: string[]) => { + return reportingConfig[keys.join('.') as string]; + }); + mockConfig = getMockConfig(mockConfigGet); }); +const getMockJob = (base: object) => base as JobDocPayloadPNG & JobDocPayloadPDF; + test(`fails if no URL is passed`, async () => { - const fn = () => - getFullUrls({ - job: {}, - server: mockServer, - } as FullUrlsOpts); + const fn = () => getFullUrls({ job: getMockJob({}), config: mockConfig } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid URL fields found in Job Params! Expected \`job.relativeUrl: string\` or \`job.relativeUrls: string[]\`"` ); @@ -37,8 +49,8 @@ test(`fails if URLs are file-protocols for PNGs`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: { relativeUrl, forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl, forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -51,8 +63,8 @@ test(`fails if URLs are absolute for PNGs`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: { relativeUrl, forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl, forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -64,11 +76,11 @@ test(`fails if URLs are file-protocols for PDF`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: { + job: getMockJob({ relativeUrls: [relativeUrl], forceNow, - }, - server: mockServer, + }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -81,11 +93,11 @@ test(`fails if URLs are absolute for PDF`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: { + job: getMockJob({ relativeUrls: [relativeUrl], forceNow, - }, - server: mockServer, + }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -102,8 +114,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { const fn = () => getFullUrls({ - job: { relativeUrls, forceNow }, - server: mockServer, + job: getMockJob({ relativeUrls, forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something file://etc/passwd/#/something"` @@ -113,8 +125,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { test(`fails if URL does not route to a visualization`, async () => { const fn = () => getFullUrls({ - job: { relativeUrl: '/app/phoney' }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/phoney' }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid hash in the URL! A hash is expected for the application to route to the intended visualization."` @@ -124,8 +136,8 @@ test(`fails if URL does not route to a visualization`, async () => { test(`adds forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: { relativeUrl: '/app/kibana#/something', forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -137,8 +149,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: { relativeUrl: '/app/kibana#/something?_g=something', forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -148,8 +160,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { const urls = await getFullUrls({ - job: { relativeUrl: '/app/kibana#/something' }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/kibana#/something' }), + config: mockConfig, } as FullUrlsOpts); expect(urls[0]).toEqual('http://localhost:5601/sbp/app/kibana#/something'); @@ -158,7 +170,7 @@ test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { test(`adds forceNow to each of multiple urls`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: { + job: getMockJob({ relativeUrls: [ '/app/kibana#/something_aaa', '/app/kibana#/something_bbb', @@ -166,8 +178,8 @@ test(`adds forceNow to each of multiple urls`, async () => { '/app/kibana#/something_ddd', ], forceNow, - }, - server: mockServer, + }), + config: mockConfig, } as FullUrlsOpts); expect(urls).toEqual([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts index ca64d8632dbfe..c4b6f31019fdf 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts @@ -12,7 +12,7 @@ import { } from 'url'; import { getAbsoluteUrlFactory } from '../../../common/get_absolute_url'; import { validateUrls } from '../../../common/validate_urls'; -import { ServerFacade } from '../../../types'; +import { ReportingConfig } from '../../../server/types'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; @@ -24,19 +24,23 @@ function isPdfJob(job: JobDocPayloadPNG | JobDocPayloadPDF): job is JobDocPayloa } export function getFullUrls({ - server, + config, job, }: { - server: ServerFacade; + config: ReportingConfig; job: JobDocPayloadPDF | JobDocPayloadPNG; }) { - const config = server.config(); - + const [basePath, protocol, hostname, port] = [ + config.kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + ] as string[]; const getAbsoluteUrl = getAbsoluteUrlFactory({ - defaultBasePath: config.get('server.basePath'), - protocol: config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, - hostname: config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), - port: config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), + defaultBasePath: basePath, + protocol, + hostname, + port, }); // PDF and PNG job params put in the url differently diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts index 0cb83352d4606..07fceb603e451 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts @@ -3,17 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ServerFacade } from '../../../types'; + +import { CaptureConfig } from '../../../server/types'; import { LayoutTypes } from '../constants'; import { Layout, LayoutParams } from './layout'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; -export function createLayout(server: ServerFacade, layoutParams?: LayoutParams): Layout { +export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams): Layout { if (layoutParams && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { return new PreserveLayout(layoutParams.dimensions); } // this is the default because some jobs won't have anything specified - return new PrintLayout(server); + return new PrintLayout(captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts index 6007c2960057a..98d8dc2983653 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts @@ -3,11 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import path from 'path'; import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; -import { LevelLogger } from '../../../server/lib'; import { HeadlessChromiumDriver } from '../../../server/browsers'; -import { ServerFacade } from '../../../types'; +import { LevelLogger } from '../../../server/lib'; +import { ReportingConfigType } from '../../../server/core'; import { LayoutTypes } from '../constants'; import { getDefaultLayoutSelectors, Layout, LayoutSelectorDictionary, Size } from './layout'; import { CaptureConfig } from './types'; @@ -20,9 +21,9 @@ export class PrintLayout extends Layout { public readonly groupCount = 2; private captureConfig: CaptureConfig; - constructor(server: ServerFacade) { + constructor(captureConfig: ReportingConfigType['capture']) { super(LayoutTypes.PRINT); - this.captureConfig = server.config().get('xpack.reporting.capture'); + this.captureConfig = captureConfig; } public getCssOverridesPath() { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts index 16eb433e8a75e..57d025890d3e2 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts @@ -7,17 +7,16 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; -import { ServerFacade } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( - server: ServerFacade, + captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, logger: LevelLogger ): Promise => { - const config = server.config(); const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; let itemsCount: number; @@ -33,7 +32,7 @@ export const getNumberOfItems = async ( // we have to use this hint to wait for all of them await browser.waitForSelector( `${renderCompleteSelector},[${itemsCountAttribute}]`, - { timeout: config.get('xpack.reporting.capture.timeouts.waitForElements') }, + { timeout: captureConfig.timeouts.waitForElements }, { context: CONTEXT_READMETADATA }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 13d07bcdd6baf..75ac3dca4ffa0 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -19,12 +19,9 @@ import * as Rx from 'rxjs'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { loggingServiceMock } from '../../../../../../../../src/core/server/mocks'; import { LevelLogger } from '../../../../server/lib'; -import { - createMockBrowserDriverFactory, - createMockLayoutInstance, - createMockServer, -} from '../../../../test_helpers'; +import { createMockBrowserDriverFactory, createMockLayoutInstance } from '../../../../test_helpers'; import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; import { screenshotsObservableFactory } from './observable'; import { ElementsPositionAndAttribute } from './types'; @@ -34,8 +31,8 @@ import { ElementsPositionAndAttribute } from './types'; const mockLogger = jest.fn(loggingServiceMock.create); const logger = new LevelLogger(mockLogger()); -const __LEGACY = createMockServer({ settings: { 'xpack.reporting.capture': { loadDelay: 13 } } }); -const mockLayout = createMockLayoutInstance(__LEGACY); +const mockConfig = { timeouts: { openUrl: 13 } } as CaptureConfig; +const mockLayout = createMockLayoutInstance(mockConfig); /* * Tests @@ -48,7 +45,7 @@ describe('Screenshot Observable Pipeline', () => { }); it('pipelines a single url into screenshot and timeRange', async () => { - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index.htm'], @@ -86,7 +83,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'], @@ -136,7 +133,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, @@ -197,7 +194,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index 44c04c763f840..53a11c18abd79 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -6,24 +6,22 @@ import * as Rx from 'rxjs'; import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; -import { CaptureConfig, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; +import { HeadlessChromiumDriverFactory } from '../../../../types'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; import { getScreenshots } from './get_screenshots'; import { getTimeRange } from './get_time_range'; +import { injectCustomCss } from './inject_css'; import { openUrl } from './open_url'; import { ScreenSetupData, ScreenshotObservableOpts, ScreenshotResults } from './types'; import { waitForRenderComplete } from './wait_for_render'; import { waitForVisualizations } from './wait_for_visualizations'; -import { injectCustomCss } from './inject_css'; export function screenshotsObservableFactory( - server: ServerFacade, + captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const config = server.config(); - const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); - return function screenshotsObservable({ logger, urls, @@ -41,13 +39,13 @@ export function screenshotsObservableFactory( mergeMap(({ driver, exit$ }) => { const setup$: Rx.Observable = Rx.of(1).pipe( takeUntil(exit$), - mergeMap(() => openUrl(server, driver, url, conditionalHeaders, logger)), - mergeMap(() => getNumberOfItems(server, driver, layout, logger)), + mergeMap(() => openUrl(captureConfig, driver, url, conditionalHeaders, logger)), + mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), mergeMap(async itemsCount => { const viewport = layout.getViewport(itemsCount); await Promise.all([ driver.setViewport(viewport, logger), - waitForVisualizations(server, driver, itemsCount, layout, logger), + waitForVisualizations(captureConfig, driver, itemsCount, layout, logger), ]); }), mergeMap(async () => { @@ -60,7 +58,7 @@ export function screenshotsObservableFactory( await layout.positionElements(driver, logger); } - await waitForRenderComplete(driver, layout, captureConfig, logger); + await waitForRenderComplete(captureConfig, driver, layout, logger); }), mergeMap(async () => { return await Promise.all([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index fbae1f91a7a6a..a484dfb243563 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -5,27 +5,26 @@ */ import { i18n } from '@kbn/i18n'; -import { ConditionalHeaders, ServerFacade } from '../../../../types'; -import { LevelLogger } from '../../../../server/lib'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; +import { LevelLogger } from '../../../../server/lib'; +import { CaptureConfig } from '../../../../server/types'; +import { ConditionalHeaders } from '../../../../types'; import { PAGELOAD_SELECTOR } from '../../constants'; export const openUrl = async ( - server: ServerFacade, + captureConfig: CaptureConfig, browser: HeadlessBrowser, url: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { - const config = server.config(); - try { await browser.open( url, { conditionalHeaders, waitForSelector: PAGELOAD_SELECTOR, - timeout: config.get('xpack.reporting.capture.timeouts.openUrl'), + timeout: captureConfig.timeouts.openUrl, }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts index ab81a952f345c..76613c2d631d6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElementPosition, ConditionalHeaders } from '../../../../types'; import { LevelLogger } from '../../../../server/lib'; +import { ConditionalHeaders, ElementPosition } from '../../../../types'; import { LayoutInstance } from '../../layouts/layout'; export interface ScreenshotObservableOpts { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts index 2f6dc2829dfd8..069896c8d9e90 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts @@ -5,16 +5,16 @@ */ import { i18n } from '@kbn/i18n'; -import { CaptureConfig } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; +import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( + captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, - captureConfig: CaptureConfig, logger: LevelLogger ) => { logger.debug( diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts index 93ad40026dff8..7960e1552e559 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts @@ -5,9 +5,9 @@ */ import { i18n } from '@kbn/i18n'; -import { ServerFacade } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; +import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -23,13 +23,12 @@ const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { * 3. Wait for the render complete event to be fired once for each item */ export const waitForVisualizations = async ( - server: ServerFacade, + captureConfig: CaptureConfig, browser: HeadlessBrowser, itemsCount: number, layout: LayoutInstance, logger: LevelLogger ): Promise => { - const config = server.config(); const { renderComplete: renderCompleteSelector } = layout.selectors; logger.debug( @@ -45,7 +44,7 @@ export const waitForVisualizations = async ( fn: getCompletedItemsCount, args: [{ renderCompleteSelector }], toEqual: itemsCount, - timeout: config.get('xpack.reporting.capture.timeouts.renderComplete'), + timeout: captureConfig.timeouts.renderComplete, }, { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, logger diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts index 7ea67277015ab..b87403ac74f89 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts @@ -11,14 +11,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, - ServerFacade, } from '../../../types'; import { JobParamsDiscoverCsv } from '../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJob( jobParams: JobParamsDiscoverCsv, diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js index f12916b734dbf..7dfa705901fbe 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js @@ -36,11 +36,12 @@ describe('CSV Execute Job', function() { let defaultElasticsearchResponse; let encryptedHeaders; - let cancellationToken; - let mockReportingPlugin; - let mockServer; let clusterStub; + let configGetStub; + let mockReportingConfig; + let mockReportingPlugin; let callAsCurrentUserStub; + let cancellationToken; const mockElasticsearch = { dataClient: { @@ -58,7 +59,17 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin = await createMockReportingCore(); - mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; + + configGetStub = sinon.stub(); + configGetStub.withArgs('encryptionKey').returns(encryptionKey); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(1024 * 1000); // 1mB + configGetStub.withArgs('csv', 'scroll').returns({}); + mockReportingConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; + + mockReportingPlugin.getConfig = () => Promise.resolve(mockReportingConfig); + mockReportingPlugin.getUiSettingsServiceFactory = () => Promise.resolve(mockUiSettingsClient); + mockReportingPlugin.getElasticsearchService = () => Promise.resolve(mockElasticsearch); + cancellationToken = new CancellationToken(); defaultElasticsearchResponse = { @@ -75,7 +86,6 @@ describe('CSV Execute Job', function() { .stub(clusterStub, 'callAsCurrentUser') .resolves(defaultElasticsearchResponse); - const configGetStub = sinon.stub(); mockUiSettingsClient.get.withArgs('csv:separator').returns(','); mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); @@ -93,36 +103,11 @@ describe('CSV Execute Job', function() { return fieldFormatsRegistry; }, }); - - mockServer = { - config: function() { - return { - get: configGetStub, - }; - }, - }; - mockServer - .config() - .get.withArgs('xpack.reporting.encryptionKey') - .returns(encryptionKey); - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(1024 * 1000); // 1mB - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({}); }); describe('basic Elasticsearch call behavior', function() { it('should decrypt encrypted headers and pass to callAsCurrentUser', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -138,12 +123,7 @@ describe('CSV Execute Job', function() { testBody: true, }; - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const job = { headers: encryptedHeaders, fields: [], @@ -170,12 +150,7 @@ describe('CSV Execute Job', function() { _scroll_id: scrollId, }); callAsCurrentUserStub.onSecondCall().resolves(defaultElasticsearchResponse); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -189,12 +164,7 @@ describe('CSV Execute Job', function() { }); it('should not execute scroll if there are no hits from the search', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -224,12 +194,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -264,12 +229,7 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -297,12 +257,7 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -321,10 +276,7 @@ describe('CSV Execute Job', function() { describe('Cells with formula values', () => { it('returns `csv_contains_formulas` when cells contain formulas', async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(true); + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -332,12 +284,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -354,10 +301,7 @@ describe('CSV Execute Job', function() { }); it('returns warnings when headings contain formulas', async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(true); + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { '=SUM(A1:A2)': 'foo', two: 'bar' } }], @@ -365,12 +309,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['=SUM(A1:A2)', 'two'], @@ -387,10 +326,7 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when cells have no formulas', async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(true); + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -398,12 +334,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -420,10 +351,7 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when configured not to', async () => { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(false); + configGetStub.withArgs('csv', 'checkForFormulas').returns(false); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -431,12 +359,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -456,12 +379,7 @@ describe('CSV Execute Job', function() { describe('Elasticsearch call errors', function() { it('should reject Promise if search call errors out', async function() { callAsCurrentUserStub.rejects(new Error()); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -480,12 +398,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); callAsCurrentUserStub.onSecondCall().rejects(new Error()); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -506,12 +419,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -532,12 +440,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -565,12 +468,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -598,12 +496,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -639,12 +532,7 @@ describe('CSV Execute Job', function() { }); it('should stop calling Elasticsearch when cancellationToken.cancel is called', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -659,12 +547,7 @@ describe('CSV Execute Job', function() { }); it(`shouldn't call clearScroll if it never got a scrollId`, async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -678,12 +561,7 @@ describe('CSV Execute Job', function() { }); it('should call clearScroll if it got a scrollId', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -701,12 +579,7 @@ describe('CSV Execute Job', function() { describe('csv content', function() { it('should write column headers to output, even if there are no results', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -718,12 +591,7 @@ describe('CSV Execute Job', function() { it('should use custom uiSettings csv:separator for header', async function() { mockUiSettingsClient.get.withArgs('csv:separator').returns(';'); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -735,12 +603,7 @@ describe('CSV Execute Job', function() { it('should escape column headers if uiSettings csv:quoteValues is true', async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -752,12 +615,7 @@ describe('CSV Execute Job', function() { it(`shouldn't escape column headers if uiSettings csv:quoteValues is false`, async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(false); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -768,12 +626,7 @@ describe('CSV Execute Job', function() { }); it('should write column headers to output, when there are results', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ one: '1', two: '2' }], @@ -793,12 +646,7 @@ describe('CSV Execute Job', function() { }); it('should use comma separated values of non-nested fields from _source', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -819,12 +667,7 @@ describe('CSV Execute Job', function() { }); it('should concatenate the hits from multiple responses', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -852,12 +695,7 @@ describe('CSV Execute Job', function() { }); it('should use field formatters to format fields', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -897,17 +735,9 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(1); - - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(1); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -935,17 +765,9 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(9); - - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -973,10 +795,7 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(9); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -985,12 +804,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1020,10 +834,7 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(18); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(18); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -1032,12 +843,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1065,10 +871,7 @@ describe('CSV Execute Job', function() { describe('scroll settings', function() { it('passes scroll duration to initial search call', async function() { const scrollDuration = 'test'; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({ duration: scrollDuration }); + configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -1077,12 +880,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1099,10 +897,7 @@ describe('CSV Execute Job', function() { it('passes scroll size to initial search call', async function() { const scrollSize = 100; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({ size: scrollSize }); + configGetStub.withArgs('csv', 'scroll').returns({ size: scrollSize }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -1111,12 +906,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1133,10 +923,7 @@ describe('CSV Execute Job', function() { it('passes scroll duration to subsequent scroll call', async function() { const scrollDuration = 'test'; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({ duration: scrollDuration }); + configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -1145,12 +932,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts index 1579985891053..a8249e5810d3c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts @@ -6,32 +6,26 @@ import { i18n } from '@kbn/i18n'; import Hapi from 'hapi'; -import { - ElasticsearchServiceSetup, - IUiSettingsClient, - KibanaRequest, -} from '../../../../../../../src/core/server'; +import { IUiSettingsClient, KibanaRequest } from '../../../../../../../src/core/server'; import { CSV_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; import { getFieldFormats } from '../../../server/services'; -import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger, ServerFacade } from '../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger } from '../../../types'; import { JobDocPayloadDiscoverCsv } from '../types'; import { fieldFormatMapFactory } from './lib/field_format_map'; import { createGenerateCsv } from './lib/generate_csv'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn( - reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, - parentLogger: Logger -) { - const crypto = cryptoFactory(server); - const config = server.config(); +>> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { + const [config, elasticsearch] = await Promise.all([ + reporting.getConfig(), + reporting.getElasticsearchService(), + ]); + const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_JOB_TYPE, 'execute-job']); - const serverBasePath = config.get('server.basePath'); + const serverBasePath = config.kbnConfig.get('server', 'basePath'); return async function executeJob( jobId: string, @@ -131,9 +125,9 @@ export const executeJobFactory: ExecuteJobFactory) { const response = await request; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts index 842330fa7c93f..529c195486bc6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts @@ -5,7 +5,8 @@ */ import { CancellationToken } from '../../common/cancellation_token'; -import { JobDocPayload, JobParamPostPayload, ConditionalHeaders, RequestFacade } from '../../types'; +import { ScrollConfig } from '../../server/types'; +import { JobDocPayload, JobParamPostPayload } from '../../types'; interface DocValueField { field: string; @@ -106,7 +107,7 @@ export interface GenerateCsvParams { quoteValues: boolean; timezone: string | null; maxSizeBytes: number; - scroll: { duration: string; size: number }; + scroll: ScrollConfig; checkForFormulas?: boolean; }; } diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts index 17072d311b35f..15a1c3e0a9fad 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts @@ -5,18 +5,11 @@ */ import { notFound, notImplemented } from 'boom'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; import { cryptoFactory } from '../../../../server/lib'; -import { - CreateJobFactory, - ImmediateCreateJobFn, - Logger, - RequestFacade, - ServerFacade, -} from '../../../../types'; +import { CreateJobFactory, ImmediateCreateJobFn, Logger, RequestFacade } from '../../../../types'; import { JobDocPayloadPanelCsv, JobParamsPanelCsv, @@ -37,13 +30,9 @@ interface VisData { export const createJobFactory: CreateJobFactory> = function createJobFactoryFn( - reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, - parentLogger: Logger -) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'create-job']); return async function createJob( diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts index 6bb3e73fcfe84..debcdb47919f1 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts @@ -5,7 +5,6 @@ */ import { i18n } from '@kbn/i18n'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; @@ -15,7 +14,6 @@ import { JobDocOutput, Logger, RequestFacade, - ServerFacade, } from '../../../types'; import { CsvResultFromSearch } from '../../csv/types'; import { FakeRequest, JobDocPayloadPanelCsv, JobParamsPanelCsv, SearchPanel } from '../types'; @@ -23,15 +21,11 @@ import { createGenerateCsv } from './lib'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn( - reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, - parentLogger: Logger -) { - const crypto = cryptoFactory(server); +>> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); - const generateCsv = createGenerateCsv(reporting, server, elasticsearch, parentLogger); + const generateCsv = await createGenerateCsv(reporting, parentLogger); return async function executeJob( jobId: string | null, @@ -57,11 +51,11 @@ export const executeJobFactory: ExecuteJobFactory; const serializedEncryptedHeaders = job.headers; try { decryptedHeaders = await crypto.decrypt(serializedEncryptedHeaders); @@ -79,10 +73,7 @@ export const executeJobFactory: ExecuteJobFactory { export async function generateCsvSearch( req: RequestFacade, reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: Logger, searchPanel: SearchPanel, jobParams: JobParamsDiscoverCsv @@ -159,11 +153,15 @@ export async function generateCsvSearch( }, }; + const [elasticsearch, config] = await Promise.all([ + reporting.getElasticsearchService(), + reporting.getConfig(), + ]); + const { callAsCurrentUser } = elasticsearch.dataClient.asScoped( KibanaRequest.from(req.getRawRequest()) ); const callCluster = (...params: [string, object]) => callAsCurrentUser(...params); - const config = server.config(); const uiSettings = await getUiSettings(uiConfig); const generateCsvParams: GenerateCsvParams = { @@ -176,8 +174,8 @@ export async function generateCsvSearch( cancellationToken: new CancellationToken(), settings: { ...uiSettings, - maxSizeBytes: config.get('xpack.reporting.csv.maxSizeBytes'), - scroll: config.get('xpack.reporting.csv.scroll'), + maxSizeBytes: config.get('csv', 'maxSizeBytes'), + scroll: config.get('csv', 'scroll'), timezone, }, }; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts index 6a7d5f336e238..ab14d2dd8a660 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { JobParamPostPayload, JobDocPayload, ServerFacade } from '../../types'; +import { JobDocPayload, JobParamPostPayload } from '../../types'; export interface FakeRequest { - headers: any; - server: ServerFacade; + headers: Record; } export interface JobParamsPostPayloadPanelCsv extends JobParamPostPayload { diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts index a6911e1f14704..9aac612677094 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, - ServerFacade, } from '../../../../types'; import { JobParamsPNG } from '../../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJob( { objectType, title, relativeUrl, browserTimezone, layout }: JobParamsPNG, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js index e2e6ba1b89096..267321d33809d 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js @@ -5,7 +5,6 @@ */ import * as Rx from 'rxjs'; -import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -14,63 +13,70 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); +let mockReporting; +let mockReportingConfig; + const cancellationToken = { on: jest.fn(), }; -let config; -let mockServer; -let mockReporting; +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const mockEncryptionKey = 'abcabcsecuresecret'; +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; beforeEach(async () => { mockReporting = await createMockReportingCore(); - config = { - 'xpack.reporting.encryptionKey': 'testencryptionkey', + const kbnConfig = { 'server.basePath': '/sbp', - 'server.host': 'localhost', - 'server.port': 5601, }; - mockServer = { - config: memoize(() => ({ get: jest.fn() })), - info: { - protocol: 'http', + const reportingConfig = { + encryptionKey: mockEncryptionKey, + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + }; + + const mockGetConfig = jest.fn(); + mockReportingConfig = { + get: (...keys) => reportingConfig[keys.join('.')], + kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, + }; + mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); + mockReporting.getConfig = mockGetConfig; + + const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), }, }; - mockServer.config().get.mockImplementation(key => { - return config[key]; - }); + const mockGetElasticsearch = jest.fn(); + mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); + mockReporting.getElasticsearchService = mockGetElasticsearch; generatePngObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePngObservableFactory.mockReset()); -const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), - }, -}; - -const getMockLogger = () => new LevelLogger(); - -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockServer); - return await crypto.encrypt(headers); -}; - test(`passes browserTimezone to generatePng`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger() - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; await executeJob( 'pngJobId', @@ -88,15 +94,7 @@ test(`passes browserTimezone to generatePng`, async () => { }); test(`returns content_type of application/png`, async () => { - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger(), - { - browserDriverFactory: {}, - } - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); @@ -116,15 +114,7 @@ test(`returns content of generatePng getBuffer base64 encoded`, async () => { const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger(), - { - browserDriverFactory: {}, - } - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pngJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts index 8670f0027af89..c53c20efec247 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts @@ -4,18 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PNG_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { - ESQueueWorkerExecuteFn, - ExecuteJobFactory, - JobDocOutput, - Logger, - ServerFacade, -} from '../../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -29,22 +22,24 @@ type QueuedPngExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ server, job, logger })), + mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), + map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), mergeMap(conditionalHeaders => { - const urls = getFullUrls({ server, job }); + const urls = getFullUrls({ config, job }); const hashUrl = urls[0]; return generatePngObservable( jobLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts index 88e91982adc63..a15541d99f6fb 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts @@ -7,17 +7,18 @@ import * as Rx from 'rxjs'; import { map } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; import { LayoutParams } from '../../../common/layouts/layout'; import { PreserveLayout } from '../../../common/layouts/preserve_layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; import { ScreenshotResults } from '../../../common/lib/screenshots/types'; export function generatePngObservableFactory( - server: ServerFacade, + captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); return function generatePngObservable( logger: LevelLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts index 656c99991e1f6..8e1d5404a5984 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, - ServerFacade, } from '../../../../types'; import { JobParamsPDF } from '../../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJobFn( { title, relativeUrls, browserTimezone, layout, objectType }: JobParamsPDF, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js index 484842ba18f2a..29769108bf4ac 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js @@ -5,7 +5,6 @@ */ import * as Rx from 'rxjs'; -import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -14,57 +13,65 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); +let mockReporting; +let mockReportingConfig; + const cancellationToken = { on: jest.fn(), }; -let config; -let mockServer; -let mockReporting; +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const mockEncryptionKey = 'testencryptionkey'; +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; beforeEach(async () => { mockReporting = await createMockReportingCore(); - config = { - 'xpack.reporting.encryptionKey': 'testencryptionkey', + const kbnConfig = { 'server.basePath': '/sbp', - 'server.host': 'localhost', - 'server.port': 5601, }; - mockServer = { - config: memoize(() => ({ get: jest.fn() })), - info: { - protocol: 'http', + const reportingConfig = { + encryptionKey: mockEncryptionKey, + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + }; + + const mockGetConfig = jest.fn(); + mockReportingConfig = { + get: (...keys) => reportingConfig[keys.join('.')], + kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, + }; + mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); + mockReporting.getConfig = mockGetConfig; + + const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), }, }; - mockServer.config().get.mockImplementation(key => { - return config[key]; - }); + const mockGetElasticsearch = jest.fn(); + mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); + mockReporting.getElasticsearchService = mockGetElasticsearch; generatePdfObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePdfObservableFactory.mockReset()); -const getMockLogger = () => new LevelLogger(); -const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), - }, -}; - -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockServer); - return await crypto.encrypt(headers); -}; - test(`returns content_type of application/pdf`, async () => { - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger() - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = generatePdfObservableFactory(); @@ -84,12 +91,7 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const generatePdfObservable = generatePdfObservableFactory(); generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger() - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pdfJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts index 535c2dcd439a7..e614db46c5730 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts @@ -4,18 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { - ESQueueWorkerExecuteFn, - ExecuteJobFactory, - JobDocOutput, - Logger, - ServerFacade, -} from '../../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -30,23 +23,25 @@ type QueuedPdfExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ server, job, logger })), + mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), - mergeMap(conditionalHeaders => getCustomLogo({ reporting, server, job, conditionalHeaders })), + map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), + mergeMap(conditionalHeaders => getCustomLogo({ reporting, config, job, conditionalHeaders })), mergeMap(({ logo, conditionalHeaders }) => { - const urls = getFullUrls({ server, job }); + const urls = getFullUrls({ config, job }); const { browserTimezone, layout, title } = job; return generatePdfObservable( diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts index d78effaa1fc2f..7021fae983aa2 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts @@ -8,7 +8,8 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; +import { ReportingConfigType } from '../../../../server/core'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; import { createLayout } from '../../../common/layouts'; import { LayoutInstance, LayoutParams } from '../../../common/layouts/layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; @@ -27,10 +28,10 @@ const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { }; export function generatePdfObservableFactory( - server: ServerFacade, + captureConfig: ReportingConfigType['capture'], browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); return function generatePdfObservable( logger: LevelLogger, @@ -41,7 +42,7 @@ export function generatePdfObservableFactory( layoutParams: LayoutParams, logo?: string ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { - const layout = createLayout(server, layoutParams) as LayoutInstance; + const layout = createLayout(captureConfig, layoutParams) as LayoutInstance; const screenshots$ = screenshotsObservable({ logger, urls, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts index 0a9dcfe986ca6..e8dd3c5207d92 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { JobDocPayload } from '../../types'; import { LayoutInstance, LayoutParams } from '../common/layouts/layout'; -import { JobDocPayload, ServerFacade, RequestFacade } from '../../types'; // Job params: structure of incoming user request data, after being parsed from RISON export interface JobParamsPDF { diff --git a/x-pack/legacy/plugins/reporting/index.test.js b/x-pack/legacy/plugins/reporting/index.test.js deleted file mode 100644 index 0d9a717bd7d81..0000000000000 --- a/x-pack/legacy/plugins/reporting/index.test.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { reporting } from './index'; -import { getConfigSchema } from '../../../test_utils'; - -// The snapshot records the number of cpus available -// to make the snapshot deterministic `os.cpus` needs to be mocked -// but the other members on `os` must remain untouched -jest.mock('os', () => { - const os = jest.requireActual('os'); - os.cpus = () => [{}, {}, {}, {}]; - return os; -}); - -// eslint-disable-next-line jest/valid-describe -const describeWithContext = describe.each([ - [{ dev: false, dist: false }], - [{ dev: true, dist: false }], - [{ dev: false, dist: true }], - [{ dev: true, dist: true }], -]); - -describeWithContext('config schema with context %j', context => { - it('produces correct config', async () => { - const schema = await getConfigSchema(reporting); - const value = await schema.validate({}, { context }); - value.capture.browser.chromium.disableSandbox = ''; - await expect(value).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/index.ts b/x-pack/legacy/plugins/reporting/index.ts index 89e98302cddc9..fb95e2c2edc24 100644 --- a/x-pack/legacy/plugins/reporting/index.ts +++ b/x-pack/legacy/plugins/reporting/index.ts @@ -8,21 +8,16 @@ import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { resolve } from 'path'; import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from './common/constants'; -import { config as reportingConfig } from './config'; import { legacyInit } from './server/legacy'; import { ReportingPluginSpecOptions } from './types'; -const kbToBase64Length = (kb: number) => { - return Math.floor((kb * 1024 * 8) / 6); -}; +const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); export const reporting = (kibana: any) => { return new kibana.Plugin({ id: PLUGIN_ID, - configPrefix: 'xpack.reporting', publicDir: resolve(__dirname, 'public'), require: ['kibana', 'elasticsearch', 'xpack_main'], - config: reportingConfig, uiExports: { uiSettingDefaults: { @@ -49,14 +44,5 @@ export const reporting = (kibana: any) => { async init(server: Legacy.Server) { return legacyInit(server, this); }, - - deprecations({ unused }: any) { - return [ - unused('capture.concurrency'), - unused('capture.timeout'), - unused('capture.settleTime'), - unused('kibanaApp'), - ]; - }, } as ReportingPluginSpecOptions); }; diff --git a/x-pack/legacy/plugins/reporting/log_configuration.ts b/x-pack/legacy/plugins/reporting/log_configuration.ts index b07475df6304f..7aaed2038bd52 100644 --- a/x-pack/legacy/plugins/reporting/log_configuration.ts +++ b/x-pack/legacy/plugins/reporting/log_configuration.ts @@ -6,22 +6,23 @@ import getosSync, { LinuxOs } from 'getos'; import { promisify } from 'util'; -import { ServerFacade, Logger } from './types'; +import { BROWSER_TYPE } from './common/constants'; +import { CaptureConfig } from './server/types'; +import { Logger } from './types'; const getos = promisify(getosSync); -export async function logConfiguration(server: ServerFacade, logger: Logger) { - const config = server.config(); +export async function logConfiguration(captureConfig: CaptureConfig, logger: Logger) { + const { + browser: { + type: browserType, + chromium: { disableSandbox }, + }, + } = captureConfig; - const browserType = config.get('xpack.reporting.capture.browser.type'); logger.debug(`Browser type: ${browserType}`); - - if (browserType === 'chromium') { - logger.debug( - `Chromium sandbox disabled: ${config.get( - 'xpack.reporting.capture.browser.chromium.disableSandbox' - )}` - ); + if (browserType === BROWSER_TYPE) { + logger.debug(`Chromium sandbox disabled: ${disableSandbox}`); } const os = await getos(); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts index dc79a6b9db2c1..a2f7a1f3ad0da 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BrowserConfig } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; + +type ViewportConfig = CaptureConfig['viewport']; +type BrowserConfig = CaptureConfig['browser']['chromium']; interface LaunchArgs { userDataDir: BrowserConfig['userDataDir']; - viewport: BrowserConfig['viewport']; + viewport: ViewportConfig; disableSandbox: BrowserConfig['disableSandbox']; proxy: BrowserConfig['proxy']; } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index f90f2c7aee395..cb228150efbcd 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -19,7 +19,8 @@ import { import * as Rx from 'rxjs'; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; -import { BrowserConfig, CaptureConfig } from '../../../../types'; +import { BROWSER_TYPE } from '../../../../common/constants'; +import { CaptureConfig } from '../../../../server/types'; import { LevelLogger as Logger } from '../../../lib/level_logger'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; @@ -28,7 +29,8 @@ import { puppeteerLaunch } from '../puppeteer'; import { args } from './args'; type binaryPath = string; -type ViewportConfig = BrowserConfig['viewport']; +type BrowserConfig = CaptureConfig['browser']['chromium']; +type ViewportConfig = CaptureConfig['viewport']; export class HeadlessChromiumDriverFactory { private binaryPath: binaryPath; @@ -37,15 +39,10 @@ export class HeadlessChromiumDriverFactory { private userDataDir: string; private getChromiumArgs: (viewport: ViewportConfig) => string[]; - constructor( - binaryPath: binaryPath, - logger: Logger, - browserConfig: BrowserConfig, - captureConfig: CaptureConfig - ) { + constructor(binaryPath: binaryPath, logger: Logger, captureConfig: CaptureConfig) { this.binaryPath = binaryPath; - this.browserConfig = browserConfig; this.captureConfig = captureConfig; + this.browserConfig = captureConfig.browser.chromium; this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-')); this.getChromiumArgs = (viewport: ViewportConfig) => @@ -57,7 +54,7 @@ export class HeadlessChromiumDriverFactory { }); } - type = 'chromium'; + type = BROWSER_TYPE; test(logger: Logger) { const chromiumArgs = args({ @@ -153,7 +150,7 @@ export class HeadlessChromiumDriverFactory { // HeadlessChromiumDriver: object to "drive" a browser page const driver = new HeadlessChromiumDriver(page, { - inspect: this.browserConfig.inspect, + inspect: !!this.browserConfig.inspect, networkPolicy: this.captureConfig.networkPolicy, }); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts index d32338ae3e311..5f89662c94da2 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BrowserConfig, CaptureConfig } from '../../../types'; +import { CaptureConfig } from '../../../server/types'; import { LevelLogger } from '../../lib'; import { HeadlessChromiumDriverFactory } from './driver_factory'; @@ -13,8 +13,7 @@ export { paths } from './paths'; export async function createDriverFactory( binaryPath: string, logger: LevelLogger, - browserConfig: BrowserConfig, captureConfig: CaptureConfig ): Promise { - return new HeadlessChromiumDriverFactory(binaryPath, logger, browserConfig, captureConfig); + return new HeadlessChromiumDriverFactory(binaryPath, logger, captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts index 49c6222c9f276..af3b86919dc50 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts @@ -4,24 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Logger } from '../../types'; +import { ReportingConfig } from '../types'; +import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; import { ensureBrowserDownloaded } from './download'; -import { installBrowser } from './install'; -import { ServerFacade, CaptureConfig, Logger } from '../../types'; -import { BROWSER_TYPE } from '../../common/constants'; import { chromium } from './index'; -import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; +import { installBrowser } from './install'; export async function createBrowserDriverFactory( - server: ServerFacade, + config: ReportingConfig, logger: Logger ): Promise { - const config = server.config(); - - const dataDir: string = config.get('path.data'); - const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); - const browserType = captureConfig.browser.type; + const captureConfig = config.get('capture'); + const browserConfig = captureConfig.browser.chromium; const browserAutoDownload = captureConfig.browser.autoDownload; - const browserConfig = captureConfig.browser[BROWSER_TYPE]; + const browserType = captureConfig.browser.type; + const dataDir = config.kbnConfig.get('path', 'data'); if (browserConfig.disableSandbox) { logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`); @@ -32,7 +30,7 @@ export async function createBrowserDriverFactory( try { const { binaryPath } = await installBrowser(logger, chromium, dataDir); - return chromium.createDriverFactory(binaryPath, logger, browserConfig, captureConfig); + return chromium.createDriverFactory(binaryPath, logger, captureConfig); } catch (error) { if (error.cause && ['EACCES', 'EEXIST'].includes(error.cause.code)) { logger.error( diff --git a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts index 73186966e3d2f..3697c4b86ce3c 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts @@ -4,16 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { resolve as resolvePath } from 'path'; import { existsSync } from 'fs'; - +import { resolve as resolvePath } from 'path'; +import { BROWSER_TYPE } from '../../../common/constants'; import { chromium } from '../index'; -import { BrowserDownload, BrowserType } from '../types'; - +import { BrowserDownload } from '../types'; import { md5 } from './checksum'; -import { asyncMap } from './util'; -import { download } from './download'; import { clean } from './clean'; +import { download } from './download'; +import { asyncMap } from './util'; /** * Check for the downloaded archive of each requested browser type and @@ -21,7 +20,7 @@ import { clean } from './clean'; * @param {String} browserType * @return {Promise} */ -export async function ensureBrowserDownloaded(browserType: BrowserType) { +export async function ensureBrowserDownloaded(browserType = BROWSER_TYPE) { await ensureDownloaded([chromium]); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts index b36345c08bfee..9714c5965a5db 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts @@ -6,12 +6,7 @@ import * as _ from 'lodash'; import { parse } from 'url'; - -interface FirewallRule { - allow: boolean; - host?: string; - protocol?: string; -} +import { NetworkPolicyRule } from '../../types'; const isHostMatch = (actualHost: string, ruleHost: string) => { const hostParts = actualHost.split('.').reverse(); @@ -20,7 +15,7 @@ const isHostMatch = (actualHost: string, ruleHost: string) => { return _.every(ruleParts, (part, idx) => part === hostParts[idx]); }; -export const allowRequest = (url: string, rules: FirewallRule[]) => { +export const allowRequest = (url: string, rules: NetworkPolicyRule[]) => { const parsed = parse(url); if (!rules.length) { diff --git a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts index 0c480fc82752b..f096073ec2f5f 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export type BrowserType = 'chromium'; - export interface BrowserDownload { paths: { archivesPath: string; diff --git a/x-pack/legacy/plugins/reporting/server/config/config.js b/x-pack/legacy/plugins/reporting/server/config/config.js deleted file mode 100644 index 08e4db464b003..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/config/config.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { cpus } from 'os'; - -const defaultCPUCount = 2; - -function cpuCount() { - try { - return cpus().length; - } catch (e) { - return defaultCPUCount; - } -} - -export const config = { - concurrency: cpuCount(), -}; diff --git a/x-pack/legacy/plugins/reporting/server/core.ts b/x-pack/legacy/plugins/reporting/server/core.ts index 4506d41e4f5c3..c233a63833950 100644 --- a/x-pack/legacy/plugins/reporting/server/core.ts +++ b/x-pack/legacy/plugins/reporting/server/core.ts @@ -7,12 +7,14 @@ import * as Rx from 'rxjs'; import { first, mapTo } from 'rxjs/operators'; import { + ElasticsearchServiceSetup, IUiSettingsClient, KibanaRequest, SavedObjectsClient, SavedObjectsServiceStart, UiSettingsServiceStart, } from 'src/core/server'; +import { ConfigType as ReportingConfigType } from '../../../../plugins/reporting/server'; // @ts-ignore no module definition import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; @@ -25,14 +27,63 @@ import { ReportingSetupDeps } from './types'; interface ReportingInternalSetup { browserDriverFactory: HeadlessChromiumDriverFactory; + config: ReportingConfig; + elasticsearch: ElasticsearchServiceSetup; } interface ReportingInternalStart { + enqueueJob: EnqueueJobFn; + esqueue: ESQueueInstance; savedObjects: SavedObjectsServiceStart; uiSettings: UiSettingsServiceStart; - esqueue: ESQueueInstance; - enqueueJob: EnqueueJobFn; } +// make config.get() aware of the value type it returns +interface Config { + get(key1: Key1): BaseType[Key1]; + get( + key1: Key1, + key2: Key2 + ): BaseType[Key1][Key2]; + get< + Key1 extends keyof BaseType, + Key2 extends keyof BaseType[Key1], + Key3 extends keyof BaseType[Key1][Key2] + >( + key1: Key1, + key2: Key2, + key3: Key3 + ): BaseType[Key1][Key2][Key3]; + get< + Key1 extends keyof BaseType, + Key2 extends keyof BaseType[Key1], + Key3 extends keyof BaseType[Key1][Key2], + Key4 extends keyof BaseType[Key1][Key2][Key3] + >( + key1: Key1, + key2: Key2, + key3: Key3, + key4: Key4 + ): BaseType[Key1][Key2][Key3][Key4]; +} + +interface KbnServerConfigType { + path: { data: string }; + server: { + basePath: string; + host: string; + name: string; + port: number; + protocol: string; + uuid: string; + }; +} + +export interface ReportingConfig extends Config { + kbnConfig: Config; +} + +export { ReportingConfigType }; + export class ReportingCore { private pluginSetupDeps?: ReportingInternalSetup; private pluginStartDeps?: ReportingInternalStart; @@ -45,6 +96,7 @@ export class ReportingCore { legacySetup( xpackMainPlugin: XPackMainPlugin, reporting: ReportingPluginSpecOptions, + config: ReportingConfig, __LEGACY: ServerFacade, plugins: ReportingSetupDeps ) { @@ -56,7 +108,7 @@ export class ReportingCore { xpackMainPlugin.info.feature(PLUGIN_ID).registerLicenseCheckResultsGenerator(checkLicense); }); // Reporting routes - registerRoutes(this, __LEGACY, plugins, this.logger); + registerRoutes(this, config, __LEGACY, plugins, this.logger); } public pluginSetup(reportingSetupDeps: ReportingInternalSetup) { @@ -90,23 +142,31 @@ export class ReportingCore { return (await this.getPluginSetupDeps()).browserDriverFactory; } + public async getConfig(): Promise { + return (await this.getPluginSetupDeps()).config; + } + /* - * Kibana core module dependencies + * Outside dependencies */ - private async getPluginSetupDeps() { + private async getPluginSetupDeps(): Promise { if (this.pluginSetupDeps) { return this.pluginSetupDeps; } return await this.pluginSetup$.pipe(first()).toPromise(); } - private async getPluginStartDeps() { + private async getPluginStartDeps(): Promise { if (this.pluginStartDeps) { return this.pluginStartDeps; } return await this.pluginStart$.pipe(first()).toPromise(); } + public async getElasticsearchService(): Promise { + return (await this.getPluginSetupDeps()).elasticsearch; + } + public async getSavedObjectsClient(fakeRequest: KibanaRequest): Promise { const { savedObjects } = await this.getPluginStartDeps(); return savedObjects.getScopedClient(fakeRequest) as SavedObjectsClient; diff --git a/x-pack/legacy/plugins/reporting/server/index.ts b/x-pack/legacy/plugins/reporting/server/index.ts index 24e2a954415d9..efcfd6b7f783d 100644 --- a/x-pack/legacy/plugins/reporting/server/index.ts +++ b/x-pack/legacy/plugins/reporting/server/index.ts @@ -11,5 +11,5 @@ export const plugin = (context: PluginInitializerContext) => { return new Plugin(context); }; -export { ReportingCore } from './core'; export { ReportingPlugin } from './plugin'; +export { ReportingConfig, ReportingCore } from './core'; diff --git a/x-pack/legacy/plugins/reporting/server/legacy.ts b/x-pack/legacy/plugins/reporting/server/legacy.ts index 336ff5f4d2ee7..29e5af529767e 100644 --- a/x-pack/legacy/plugins/reporting/server/legacy.ts +++ b/x-pack/legacy/plugins/reporting/server/legacy.ts @@ -4,35 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ import { Legacy } from 'kibana'; -import { PluginInitializerContext } from 'src/core/server'; +import { get } from 'lodash'; +import { take } from 'rxjs/operators'; +import { CoreSetup, PluginInitializerContext } from 'src/core/server'; +import { ConfigType, PluginsSetup } from '../../../../plugins/reporting/server'; import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { ReportingPluginSpecOptions } from '../types'; import { plugin } from './index'; -import { LegacySetup, ReportingStartDeps } from './types'; +import { LegacySetup, ReportingConfig, ReportingStartDeps } from './types'; const buildLegacyDependencies = ( + coreSetup: CoreSetup, server: Legacy.Server, reportingPlugin: ReportingPluginSpecOptions -): LegacySetup => ({ - config: server.config, - info: server.info, - route: server.route.bind(server), - plugins: { - elasticsearch: server.plugins.elasticsearch, - xpack_main: server.plugins.xpack_main, - reporting: reportingPlugin, - }, -}); +): LegacySetup => { + return { + route: server.route.bind(server), + plugins: { + xpack_main: server.plugins.xpack_main, + reporting: reportingPlugin, + }, + }; +}; + +const buildConfig = ( + coreSetup: CoreSetup, + server: Legacy.Server, + reportingConfig: ConfigType +): ReportingConfig => { + const config = server.config(); + const { http } = coreSetup; + const serverInfo = http.getServerInfo(); + + const kbnConfig = { + path: { + data: config.get('path.data'), // FIXME: get from the real PluginInitializerContext + }, + server: { + basePath: coreSetup.http.basePath.serverBasePath, + host: serverInfo.host, + name: serverInfo.name, + port: serverInfo.port, + uuid: coreSetup.uuid.getInstanceUuid(), + protocol: serverInfo.protocol, + }, + }; + + // spreading arguments as an array allows the return type to be known by the compiler + return { + get: (...keys: string[]) => get(reportingConfig, keys.join('.'), null), + kbnConfig: { + get: (...keys: string[]) => get(kbnConfig, keys.join('.'), null), + }, + }; +}; export const legacyInit = async ( server: Legacy.Server, - reportingPlugin: ReportingPluginSpecOptions + reportingLegacyPlugin: ReportingPluginSpecOptions ) => { - const coreSetup = server.newPlatform.setup.core; - const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); + const { core: coreSetup } = server.newPlatform.setup; + const { config$ } = (server.newPlatform.setup.plugins.reporting as PluginsSetup).__legacy; + const reportingConfig = await config$.pipe(take(1)).toPromise(); + const reporting = { config: buildConfig(coreSetup, server, reportingConfig) }; + + const __LEGACY = buildLegacyDependencies(coreSetup, server, reportingLegacyPlugin); - const __LEGACY = buildLegacyDependencies(server, reportingPlugin); + const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); // NOTE: mocked-out PluginInitializerContext await pluginInstance.setup(coreSetup, { + reporting, elasticsearch: coreSetup.elasticsearch, security: server.newPlatform.setup.plugins.security as SecurityPluginSetup, usageCollection: server.newPlatform.setup.plugins.usageCollection, @@ -42,7 +82,6 @@ export const legacyInit = async ( // Schedule to call the "start" hook only after start dependencies are ready coreSetup.getStartServices().then(([core, plugins]) => pluginInstance.start(core, { - elasticsearch: coreSetup.elasticsearch, data: (plugins as ReportingStartDeps).data, __LEGACY, }) diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts index d593e4625cdf4..a05205526dd3e 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; -import { ESQueueInstance, ServerFacade, QueueConfig, Logger } from '../../types'; +import { ESQueueInstance, Logger } from '../../types'; import { ReportingCore } from '../core'; +import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed +import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; -import { createWorkerFactory } from './create_worker'; -import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed export async function createQueueFactory( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: Logger ): Promise { - const queueConfig: QueueConfig = server.config().get('xpack.reporting.queue'); - const index = server.config().get('xpack.reporting.index'); + const [config, elasticsearch] = await Promise.all([ + reporting.getConfig(), + reporting.getElasticsearchService(), + ]); + + const queueConfig = config.get('queue'); + const index = config.get('index'); const queueOptions = { interval: queueConfig.indexInterval, @@ -33,7 +35,7 @@ export async function createQueueFactory( if (queueConfig.pollEnabled) { // create workers to poll the index for idle jobs waiting to be claimed and executed - const createWorker = createWorkerFactory(reporting, server, elasticsearch, logger); + const createWorker = await createWorkerFactory(reporting, config, logger); await createWorker(queue); } else { logger.info( diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts index d4d913243e18d..01a937a49873a 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import * as sinon from 'sinon'; -import { ReportingCore } from '../../server'; +import { ReportingConfig, ReportingCore } from '../../server/types'; import { createMockReportingCore } from '../../test_helpers'; -import { ServerFacade } from '../../types'; import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; @@ -17,21 +15,15 @@ import { ClientMock } from './esqueue/__tests__/fixtures/legacy_elasticsearch'; import { ExportTypesRegistry } from './export_types_registry'; const configGetStub = sinon.stub(); -configGetStub.withArgs('xpack.reporting.queue').returns({ +configGetStub.withArgs('queue').returns({ pollInterval: 3300, pollIntervalErrorMultiplier: 10, }); -configGetStub.withArgs('server.name').returns('test-server-123'); -configGetStub.withArgs('server.uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); +configGetStub.withArgs('server', 'name').returns('test-server-123'); +configGetStub.withArgs('server', 'uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); const executeJobFactoryStub = sinon.stub(); - -const getMockServer = (): ServerFacade => { - return ({ - config: () => ({ get: configGetStub }), - } as unknown) as ServerFacade; -}; -const getMockLogger = jest.fn(); +const getMockLogger = sinon.stub(); const getMockExportTypesRegistry = ( exportTypes: any[] = [{ executeJobFactory: executeJobFactoryStub }] @@ -41,25 +33,23 @@ const getMockExportTypesRegistry = ( } as ExportTypesRegistry); describe('Create Worker', () => { + let mockReporting: ReportingCore; + let mockConfig: ReportingConfig; let queue: Esqueue; let client: ClientMock; - let mockReporting: ReportingCore; beforeEach(async () => { mockReporting = await createMockReportingCore(); + mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); + mockConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; + mockReporting.getConfig = () => Promise.resolve(mockConfig); client = new ClientMock(); queue = new Esqueue('reporting-queue', { client }); executeJobFactoryStub.reset(); }); test('Creates a single Esqueue worker for Reporting', async () => { - mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); - const createWorker = createWorkerFactory( - mockReporting, - getMockServer(), - {} as ElasticsearchServiceSetup, - getMockLogger() - ); + const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); @@ -91,12 +81,7 @@ Object { { executeJobFactory: executeJobFactoryStub }, ]); mockReporting.getExportTypesRegistry = () => exportTypesRegistry; - const createWorker = createWorkerFactory( - mockReporting, - getMockServer(), - {} as ElasticsearchServiceSetup, - getMockLogger() - ); + const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts index 3567712367608..e9d0acf29c721 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import { CancellationToken } from '../../common/cancellation_token'; import { PLUGIN_ID } from '../../common/constants'; +import { ReportingConfig } from '../../server/types'; import { ESQueueInstance, ESQueueWorkerExecuteFn, @@ -15,25 +15,22 @@ import { JobDocPayload, JobSource, Logger, - QueueConfig, RequestFacade, - ServerFacade, } from '../../types'; import { ReportingCore } from '../core'; // @ts-ignore untyped dependency import { events as esqueueEvents } from './esqueue'; -export function createWorkerFactory( +export async function createWorkerFactory( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, + config: ReportingConfig, logger: Logger ) { type JobDocPayloadType = JobDocPayload; - const config = server.config(); - const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); - const kibanaName: string = config.get('server.name'); - const kibanaId: string = config.get('server.uuid'); + + const queueConfig = config.get('queue'); + const kibanaName = config.kbnConfig.get('server', 'name'); + const kibanaId = config.kbnConfig.get('server', 'uuid'); // Once more document types are added, this will need to be passed in return async function createWorker(queue: ESQueueInstance) { @@ -47,12 +44,7 @@ export function createWorkerFactory( ExportTypeDefinition >) { // TODO: the executeJobFn should be unwrapped in the register method of the export types registry - const jobExecutor = await exportType.executeJobFactory( - reporting, - server, - elasticsearch, - logger - ); + const jobExecutor = await exportType.executeJobFactory(reporting, logger); jobExecutors.set(exportType.jobType, jobExecutor); } diff --git a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts index dbc01fc947f8b..97876529ecfa7 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts @@ -5,12 +5,7 @@ */ import nodeCrypto from '@elastic/node-crypto'; -import { oncePerServer } from './once_per_server'; -import { ServerFacade } from '../../types'; -function cryptoFn(server: ServerFacade) { - const encryptionKey = server.config().get('xpack.reporting.encryptionKey'); +export function cryptoFactory(encryptionKey: string | undefined) { return nodeCrypto({ encryptionKey }); } - -export const cryptoFactory = oncePerServer(cryptoFn); diff --git a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts index c215bdc398904..bc4754b02ed57 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts @@ -5,22 +5,18 @@ */ import { get } from 'lodash'; -import { ElasticsearchServiceSetup } from 'kibana/server'; -// @ts-ignore -import { events as esqueueEvents } from './esqueue'; import { + ConditionalHeaders, EnqueueJobFn, ESQueueCreateJobFn, ImmediateCreateJobFn, Job, - ServerFacade, - RequestFacade, Logger, - CaptureConfig, - QueueConfig, - ConditionalHeaders, + RequestFacade, } from '../../types'; import { ReportingCore } from '../core'; +// @ts-ignore +import { events as esqueueEvents } from './esqueue'; interface ConfirmedJob { id: string; @@ -29,18 +25,16 @@ interface ConfirmedJob { _primary_term: number; } -export function enqueueJobFactory( +export async function enqueueJobFactory( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, parentLogger: Logger -): EnqueueJobFn { +): Promise { + const config = await reporting.getConfig(); const logger = parentLogger.clone(['queue-job']); - const config = server.config(); - const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); + const captureConfig = config.get('capture'); const browserType = captureConfig.browser.type; const maxAttempts = captureConfig.maxAttempts; - const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); + const queueConfig = config.get('queue'); return async function enqueueJob( exportTypeId: string, @@ -59,12 +53,7 @@ export function enqueueJobFactory( } // TODO: the createJobFn should be unwrapped in the register method of the export types registry - const createJob = exportType.createJobFactory( - reporting, - server, - elasticsearch, - logger - ) as CreateJobFn; + const createJob = (await exportType.createJobFactory(reporting, logger)) as CreateJobFn; const payload = await createJob(jobParams, headers, request); const options = { diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js index 6cdbe8f968f75..8e4047e2f22e5 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js @@ -8,6 +8,7 @@ import moment from 'moment'; export const intervals = ['year', 'month', 'week', 'day', 'hour', 'minute']; +// TODO: remove this helper by using `schema.duration` objects in the reporting config schema export function indexTimestamp(intervalStr, separator = '-') { if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); diff --git a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts index 49d5c568c3981..5e73fe77ecb79 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts @@ -6,10 +6,10 @@ import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../../../src/core/server'; -import { ServerFacade } from '../../types'; +import { Logger } from '../../types'; import { ReportingSetupDeps } from '../types'; -export function getUserFactory(server: ServerFacade, security: ReportingSetupDeps['security']) { +export function getUserFactory(security: ReportingSetupDeps['security'], logger: Logger) { /* * Legacy.Request because this is called from routing middleware */ diff --git a/x-pack/legacy/plugins/reporting/server/lib/index.ts b/x-pack/legacy/plugins/reporting/server/lib/index.ts index 0a2db749cb954..f5ccbe493a91f 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/index.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export { getExportTypesRegistry } from './export_types_registry'; export { checkLicenseFactory } from './check_license'; -export { LevelLogger } from './level_logger'; -export { cryptoFactory } from './crypto'; -export { oncePerServer } from './once_per_server'; -export { runValidations } from './validate'; export { createQueueFactory } from './create_queue'; +export { cryptoFactory } from './crypto'; export { enqueueJobFactory } from './enqueue_job'; +export { getExportTypesRegistry } from './export_types_registry'; +export { LevelLogger } from './level_logger'; +export { runValidations } from './validate'; diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts index c01e6377b039e..0affc111c1368 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts @@ -9,7 +9,8 @@ import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; -import { JobSource, ServerFacade } from '../../types'; +import { JobSource } from '../../types'; +import { ReportingConfig } from '../types'; const esErrors = elasticsearchErrors as Record; const defaultSize = 10; @@ -39,8 +40,11 @@ interface CountAggResult { count: number; } -export function jobsQueryFactory(server: ServerFacade, elasticsearch: ElasticsearchServiceSetup) { - const index = server.config().get('xpack.reporting.index'); +export function jobsQueryFactory( + config: ReportingConfig, + elasticsearch: ElasticsearchServiceSetup +) { + const index = config.get('index'); const { callAsInternalUser } = elasticsearch.adminClient; function getUsername(user: any) { diff --git a/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts b/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts deleted file mode 100644 index ae3636079a9bb..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { memoize, MemoizedFunction } from 'lodash'; -import { ServerFacade } from '../../types'; - -type ServerFn = (server: ServerFacade) => any; -type Memo = ((server: ServerFacade) => any) & MemoizedFunction; - -/** - * allow this function to be called multiple times, but - * ensure that it only received one argument, the server, - * and cache the return value so that subsequent calls get - * the exact same value. - * - * This is intended to be used by service factories like getObjectQueueFactory - * - * @param {Function} fn - the factory function - * @return {any} - */ -export function oncePerServer(fn: ServerFn) { - const memoized: Memo = memoize(function(server: ServerFacade) { - if (arguments.length !== 1) { - throw new TypeError('This function expects to be called with a single argument'); - } - - // @ts-ignore - return fn.call(this, server); - }); - - // @ts-ignore - // Type 'WeakMap' is not assignable to type 'MapCache - - // use a weak map a the cache so that: - // 1. return values mapped to the actual server instance - // 2. return value lifecycle matches that of the server - memoized.cache = new WeakMap(); - - return memoized; -} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js deleted file mode 100644 index 10980f702d849..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import sinon from 'sinon'; -import { validateEncryptionKey } from '../validate_encryption_key'; - -describe('Reporting: Validate config', () => { - const logger = { - warning: sinon.spy(), - }; - - beforeEach(() => { - logger.warning.resetHistory(); - }); - - [undefined, null].forEach(value => { - it(`should log a warning and set xpack.reporting.encryptionKey if encryptionKey is ${value}`, () => { - const config = { - get: sinon.stub().returns(value), - set: sinon.stub(), - }; - - expect(() => validateEncryptionKey({ config: () => config }, logger)).not.to.throwError(); - - sinon.assert.calledWith(config.set, 'xpack.reporting.encryptionKey'); - sinon.assert.calledWithMatch(logger.warning, /Generating a random key/); - sinon.assert.calledWithMatch(logger.warning, /please set xpack.reporting.encryptionKey/); - }); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts deleted file mode 100644 index 04f998fd3e5a5..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import sinon from 'sinon'; -import { ServerFacade } from '../../../../types'; -import { validateServerHost } from '../validate_server_host'; - -const configKey = 'xpack.reporting.kibanaServer.hostname'; - -describe('Reporting: Validate server host setting', () => { - it(`should log a warning and set ${configKey} if server.host is "0"`, () => { - const getStub = sinon.stub(); - getStub.withArgs('server.host').returns('0'); - getStub.withArgs(configKey).returns(undefined); - const config = { - get: getStub, - set: sinon.stub(), - }; - - expect(() => - validateServerHost(({ config: () => config } as unknown) as ServerFacade) - ).to.throwError(); - - sinon.assert.calledWith(config.set, configKey); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts index 0fdbd858b8e3c..85d9f727d7fa7 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts @@ -6,25 +6,22 @@ import { i18n } from '@kbn/i18n'; import { ElasticsearchServiceSetup } from 'kibana/server'; -import { Logger, ServerFacade } from '../../../types'; +import { Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; +import { ReportingConfig } from '../../types'; import { validateBrowser } from './validate_browser'; -import { validateEncryptionKey } from './validate_encryption_key'; import { validateMaxContentLength } from './validate_max_content_length'; -import { validateServerHost } from './validate_server_host'; export async function runValidations( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) { try { await Promise.all([ - validateBrowser(server, browserFactory, logger), - validateEncryptionKey(server, logger), - validateMaxContentLength(server, elasticsearch, logger), - validateServerHost(server), + validateBrowser(browserFactory, logger), + validateMaxContentLength(config, elasticsearch, logger), ]); logger.debug( i18n.translate('xpack.reporting.selfCheck.ok', { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts index 89c49123e85bf..d6512d5eb718b 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts @@ -3,9 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { Browser } from 'puppeteer'; import { BROWSER_TYPE } from '../../../common/constants'; -import { ServerFacade, Logger } from '../../../types'; +import { Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; /* @@ -13,7 +14,6 @@ import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_fa * to the locally running Kibana instance. */ export const validateBrowser = async ( - server: ServerFacade, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) => { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts deleted file mode 100644 index e0af94cbdc29c..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import crypto from 'crypto'; -import { ServerFacade, Logger } from '../../../types'; - -export function validateEncryptionKey(serverFacade: ServerFacade, logger: Logger) { - const config = serverFacade.config(); - - const encryptionKey = config.get('xpack.reporting.encryptionKey'); - if (encryptionKey == null) { - // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. - logger.warning( - i18n.translate('xpack.reporting.selfCheckEncryptionKey.warning', { - defaultMessage: - `Generating a random key for {setting}. To prevent pending reports ` + - `from failing on restart, please set {setting} in kibana.yml`, - values: { - setting: 'xpack.reporting.encryptionKey', - }, - }) - ); - - // @ts-ignore: No set() method on KibanaConfig, just get() and has() - config.set('xpack.reporting.encryptionKey', crypto.randomBytes(16).toString('hex')); // update config in memory to contain a usable encryption key - } -} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js index 942dcaf842696..2551fd48b91f3 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js @@ -32,11 +32,7 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should log warning messages when reporting has a higher max-size than elasticsearch', async () => { - const server = { - config: () => ({ - get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES), - }), - }; + const config = { get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES) }; const elasticsearch = { dataClient: { callAsInternalUser: () => ({ @@ -49,7 +45,7 @@ describe('Reporting: Validate Max Content Length', () => { }, }; - await validateMaxContentLength(server, elasticsearch, logger); + await validateMaxContentLength(config, elasticsearch, logger); sinon.assert.calledWithMatch( logger.warning, @@ -70,14 +66,10 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should do nothing when reporting has the same max-size as elasticsearch', async () => { - const server = { - config: () => ({ - get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES), - }), - }; + const config = { get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES) }; expect( - async () => await validateMaxContentLength(server, elasticsearch, logger.warning) + async () => await validateMaxContentLength(config, elasticsearch, logger.warning) ).not.toThrow(); sinon.assert.notCalled(logger.warning); }); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts index ce4a5b93e7431..a20905ba093d4 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts @@ -7,17 +7,17 @@ import numeral from '@elastic/numeral'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { defaults, get } from 'lodash'; -import { Logger, ServerFacade } from '../../../types'; +import { Logger } from '../../../types'; +import { ReportingConfig } from '../../types'; -const KIBANA_MAX_SIZE_BYTES_PATH = 'xpack.reporting.csv.maxSizeBytes'; +const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; export async function validateMaxContentLength( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup, logger: Logger ) { - const config = server.config(); const { callAsInternalUser } = elasticsearch.dataClient; const elasticClusterSettingsResponse = await callAsInternalUser('cluster.getSettings', { @@ -28,13 +28,13 @@ export async function validateMaxContentLength( const elasticSearchMaxContent = get(elasticClusterSettings, 'http.max_content_length', '100mb'); const elasticSearchMaxContentBytes = numeral().unformat(elasticSearchMaxContent.toUpperCase()); - const kibanaMaxContentBytes: number = config.get(KIBANA_MAX_SIZE_BYTES_PATH); + const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes'); if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. logger.warning( - `${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + - `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your ${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` + `xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + + `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` ); } } diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts deleted file mode 100644 index f4f4d61246b6a..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ServerFacade } from '../../../types'; - -const configKey = 'xpack.reporting.kibanaServer.hostname'; - -export function validateServerHost(serverFacade: ServerFacade) { - const config = serverFacade.config(); - - const serverHost = config.get('server.host'); - const reportingKibanaHostName = config.get(configKey); - - if (!reportingKibanaHostName && serverHost === '0') { - // @ts-ignore: No set() method on KibanaConfig, just get() and has() - config.set(configKey, '0.0.0.0'); // update config in memory to allow Reporting to work - - throw new Error( - `Found 'server.host: "0"' in settings. This is incompatible with Reporting. ` + - `To enable Reporting to work, '${configKey}: 0.0.0.0' is being automatically to the configuration. ` + - `You can change to 'server.host: 0.0.0.0' or add '${configKey}: 0.0.0.0' in kibana.yml to prevent this message.` - ); - } -} diff --git a/x-pack/legacy/plugins/reporting/server/plugin.ts b/x-pack/legacy/plugins/reporting/server/plugin.ts index 4f24cc16b2277..1d7cc075b690d 100644 --- a/x-pack/legacy/plugins/reporting/server/plugin.ts +++ b/x-pack/legacy/plugins/reporting/server/plugin.ts @@ -12,8 +12,6 @@ import { createQueueFactory, enqueueJobFactory, LevelLogger, runValidations } fr import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; -// @ts-ignore no module definition -import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; export class ReportingPlugin implements Plugin { @@ -26,29 +24,29 @@ export class ReportingPlugin } public async setup(core: CoreSetup, plugins: ReportingSetupDeps) { - const { elasticsearch, usageCollection, __LEGACY } = plugins; + const { reporting: reportingNewPlatform, elasticsearch, __LEGACY } = plugins; + const { config } = reportingNewPlatform; - const browserDriverFactory = await createBrowserDriverFactory(__LEGACY, this.logger); // required for validations :( - runValidations(__LEGACY, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults + const browserDriverFactory = await createBrowserDriverFactory(config, this.logger); // required for validations :( + runValidations(config, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults const { xpack_main: xpackMainLegacy, reporting: reportingLegacy } = __LEGACY.plugins; - this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, __LEGACY, plugins); + this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, config, __LEGACY, plugins); // Register a function with server to manage the collection of usage stats - registerReportingUsageCollector(this.reportingCore, __LEGACY, usageCollection); + registerReportingUsageCollector(this.reportingCore, config, plugins); // regsister setup internals - this.reportingCore.pluginSetup({ browserDriverFactory }); + this.reportingCore.pluginSetup({ browserDriverFactory, config, elasticsearch }); return {}; } public async start(core: CoreStart, plugins: ReportingStartDeps) { const { reportingCore, logger } = this; - const { elasticsearch, __LEGACY } = plugins; - const esqueue = await createQueueFactory(reportingCore, __LEGACY, elasticsearch, logger); - const enqueueJob = enqueueJobFactory(reportingCore, __LEGACY, elasticsearch, logger); + const esqueue = await createQueueFactory(reportingCore, logger); + const enqueueJob = await enqueueJobFactory(reportingCore, logger); this.reportingCore.pluginStart({ savedObjects: core.savedObjects, @@ -58,7 +56,9 @@ export class ReportingPlugin }); setFieldFormats(plugins.data.fieldFormats); - logConfiguration(__LEGACY, this.logger); + + const config = await reportingCore.getConfig(); + logConfiguration(config.get('capture'), this.logger); return {}; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts index 56622617586f7..dc58e97ff3e41 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts @@ -10,7 +10,7 @@ import { Legacy } from 'kibana'; import rison from 'rison-node'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingSetupDeps } from '../types'; +import { ReportingConfig, ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { GetRouteConfigFactoryFn, @@ -22,6 +22,7 @@ import { HandlerErrorFunction, HandlerFunction } from './types'; const BASE_GENERATE = `${API_BASE_URL}/generate`; export function registerGenerateFromJobParams( + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handler: HandlerFunction, @@ -30,7 +31,7 @@ export function registerGenerateFromJobParams( ) { const getRouteConfig = () => { const getOriginalRouteConfig: GetRouteConfigFactoryFn = getRouteConfigFactoryReportingPre( - server, + config, plugins, logger ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts index 415b6b7d64366..23ab7ee0d9e6b 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts @@ -9,7 +9,7 @@ import { get } from 'lodash'; import { API_BASE_GENERATE_V1, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../common/constants'; import { getJobParamsFromRequest } from '../../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingSetupDeps } from '../types'; +import { ReportingConfig, ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types'; @@ -24,13 +24,14 @@ import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types * - local (transient) changes the user made to the saved object */ export function registerGenerateCsvFromSavedObject( + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handleRoute: HandlerFunction, handleRouteError: HandlerErrorFunction, logger: Logger ) { - const routeOptions = getRouteOptionsCsv(server, plugins, logger); + const routeOptions = getRouteOptionsCsv(config, plugins, logger); server.route({ path: `${API_BASE_GENERATE_V1}/csv/saved-object/{savedObjectType}:{savedObjectId}`, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 5d17fa2e82b8c..5bd07aa6049ed 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -16,7 +16,7 @@ import { ResponseFacade, ServerFacade, } from '../../types'; -import { ReportingSetupDeps, ReportingCore } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; @@ -31,12 +31,12 @@ import { getRouteOptionsCsv } from './lib/route_config_factories'; */ export function registerGenerateCsvFromSavedObjectImmediate( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, parentLogger: Logger ) { - const routeOptions = getRouteOptionsCsv(server, plugins, parentLogger); - const { elasticsearch } = plugins; + const routeOptions = getRouteOptionsCsv(config, plugins, parentLogger); /* * CSV export with the `immediate` option does not queue a job with Reporting's ESQueue to run the job async. Instead, this does: @@ -52,14 +52,10 @@ export function registerGenerateCsvFromSavedObjectImmediate( const logger = parentLogger.clone(['savedobject-csv']); const jobParams = getJobParamsFromRequest(request, { isImmediate: true }); - /* TODO these functions should be made available in the export types registry: - * - * const { createJobFn, executeJobFn } = exportTypesRegistry.getById(CSV_FROM_SAVEDOBJECT_JOB_TYPE) - * - * Calling an execute job factory requires passing a browserDriverFactory option, so we should not call the factory from here - */ - const createJobFn = createJobFactory(reporting, server, elasticsearch, logger); - const executeJobFn = await executeJobFactory(reporting, server, elasticsearch, logger); + const [createJobFn, executeJobFn] = await Promise.all([ + createJobFactory(reporting, logger), + executeJobFactory(reporting, logger), + ]); const jobDocPayload: JobDocPayloadPanelCsv = await createJobFn( jobParams, request.headers, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts index 54d9671692c5d..44a98dac2d4a9 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts @@ -7,7 +7,7 @@ import Hapi from 'hapi'; import { createMockReportingCore } from '../../test_helpers'; import { Logger, ServerFacade } from '../../types'; -import { ReportingCore, ReportingSetupDeps } from '../../server/types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; jest.mock('./lib/authorized_user_pre_routing', () => ({ authorizedUserPreRoutingFactory: () => () => ({}), @@ -22,6 +22,8 @@ import { registerJobGenerationRoutes } from './generation'; let mockServer: Hapi.Server; let mockReportingPlugin: ReportingCore; +let mockReportingConfig: ReportingConfig; + const mockLogger = ({ error: jest.fn(), debug: jest.fn(), @@ -33,10 +35,12 @@ beforeEach(async () => { port: 8080, routes: { log: { collect: true } }, }); - mockServer.config = () => ({ get: jest.fn(), has: jest.fn() }); + mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getEnqueueJob = async () => jest.fn().mockImplementation(() => ({ toJSON: () => '{ "job": "data" }' })); + + mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -54,6 +58,7 @@ const getErrorsFromRequest = (request: Hapi.Request) => { test(`returns 400 if there are no job params`, async () => { registerJobGenerationRoutes( mockReportingPlugin, + (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -80,6 +85,7 @@ test(`returns 400 if there are no job params`, async () => { test(`returns 400 if job params is invalid`, async () => { registerJobGenerationRoutes( mockReportingPlugin, + (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -114,6 +120,7 @@ test(`returns 500 if job handler throws an error`, async () => { registerJobGenerationRoutes( mockReportingPlugin, + (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.ts index 096ba84b63d1a..0ac6a34dd75bb 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.ts @@ -9,7 +9,7 @@ import { errors as elasticsearchErrors } from 'elasticsearch'; import { Legacy } from 'kibana'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingSetupDeps, ReportingCore } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { registerGenerateFromJobParams } from './generate_from_jobparams'; import { registerGenerateCsvFromSavedObject } from './generate_from_savedobject'; import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; @@ -19,12 +19,13 @@ const esErrors = elasticsearchErrors as Record; export function registerJobGenerationRoutes( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const config = server.config(); - const DOWNLOAD_BASE_URL = config.get('server.basePath') + `${API_BASE_URL}/jobs/download`; + const DOWNLOAD_BASE_URL = + `${config.kbnConfig.get('server', 'basePath')}` + `${API_BASE_URL}/jobs/download`; /* * Generates enqueued job details to use in responses @@ -66,11 +67,11 @@ export function registerJobGenerationRoutes( return err; } - registerGenerateFromJobParams(server, plugins, handler, handleError, logger); + registerGenerateFromJobParams(config, server, plugins, handler, handleError, logger); // Register beta panel-action download-related API's - if (config.get('xpack.reporting.csv.enablePanelActionDownload')) { - registerGenerateCsvFromSavedObject(server, plugins, handler, handleError, logger); - registerGenerateCsvFromSavedObjectImmediate(reporting, server, plugins, logger); + if (config.get('csv', 'enablePanelActionDownload')) { + registerGenerateCsvFromSavedObject(config, server, plugins, handler, handleError, logger); + registerGenerateCsvFromSavedObjectImmediate(reporting, config, server, plugins, logger); } } diff --git a/x-pack/legacy/plugins/reporting/server/routes/index.ts b/x-pack/legacy/plugins/reporting/server/routes/index.ts index 610ab4907d369..21eeb901d9b96 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/index.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/index.ts @@ -5,16 +5,17 @@ */ import { Logger, ServerFacade } from '../../types'; -import { ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { registerJobGenerationRoutes } from './generation'; import { registerJobInfoRoutes } from './jobs'; export function registerRoutes( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - registerJobGenerationRoutes(reporting, server, plugins, logger); - registerJobInfoRoutes(reporting, server, plugins, logger); + registerJobGenerationRoutes(reporting, config, server, plugins, logger); + registerJobInfoRoutes(reporting, config, server, plugins, logger); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js index 071b401d2321b..b12aa44487523 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js @@ -5,7 +5,6 @@ */ import Hapi from 'hapi'; -import { memoize } from 'lodash'; import { createMockReportingCore } from '../../test_helpers'; import { ExportTypesRegistry } from '../lib/export_types_registry'; @@ -23,6 +22,7 @@ import { registerJobInfoRoutes } from './jobs'; let mockServer; let exportTypesRegistry; let mockReportingPlugin; +let mockReportingConfig; const mockLogger = { error: jest.fn(), debug: jest.fn(), @@ -30,7 +30,6 @@ const mockLogger = { beforeEach(async () => { mockServer = new Hapi.Server({ debug: false, port: 8080, routes: { log: { collect: true } } }); - mockServer.config = memoize(() => ({ get: jest.fn() })); exportTypesRegistry = new ExportTypesRegistry(); exportTypesRegistry.register({ id: 'unencoded', @@ -43,8 +42,11 @@ beforeEach(async () => { jobContentEncoding: 'base64', jobContentExtension: 'pdf', }); + mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getExportTypesRegistry = () => exportTypesRegistry; + + mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -70,7 +72,13 @@ test(`returns 404 if job not found`, async () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -89,7 +97,13 @@ test(`returns 401 if not valid job type`, async () => { .mockReturnValue(Promise.resolve(getHits({ jobtype: 'invalidJobType' }))), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -110,7 +124,13 @@ describe(`when job is incomplete`, () => { ), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -152,7 +172,13 @@ describe(`when job is failed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -197,7 +223,13 @@ describe(`when job is completed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts index b9aa75e0ddd00..4f29e561431fa 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts @@ -17,7 +17,7 @@ import { ServerFacade, } from '../../types'; import { jobsQueryFactory } from '../lib/jobs_query'; -import { ReportingSetupDeps, ReportingCore } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { deleteJobResponseHandlerFactory, downloadJobResponseHandlerFactory, @@ -37,13 +37,14 @@ function isResponse(response: Boom | ResponseObject): response is Response export function registerJobInfoRoutes( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { const { elasticsearch } = plugins; - const jobsQuery = jobsQueryFactory(server, elasticsearch); - const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + const jobsQuery = jobsQueryFactory(config, elasticsearch); + const getRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); // list jobs in the queue, paginated server.route({ @@ -141,8 +142,8 @@ export function registerJobInfoRoutes( // trigger a download of the output from a job const exportTypesRegistry = reporting.getExportTypesRegistry(); - const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); - const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore + const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(config, plugins, logger); + const downloadResponseHandler = downloadJobResponseHandlerFactory(config, elasticsearch, exportTypesRegistry); // prettier-ignore server.route({ path: `${MAIN_ENTRY}/download/{docId}`, method: 'GET', @@ -181,8 +182,8 @@ export function registerJobInfoRoutes( }); // allow a report to be deleted - const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger); - const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch); + const getRouteConfigDelete = getRouteConfigFactoryDeletePre(config, plugins, logger); + const deleteResponseHandler = deleteJobResponseHandlerFactory(config, elasticsearch); server.route({ path: `${MAIN_ENTRY}/delete/{docId}`, method: 'DELETE', diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js index 3460d22592e3d..b5d6ae59ce5dd 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js @@ -7,56 +7,48 @@ import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; describe('authorized_user_pre_routing', function() { - // the getClientShield is using `once` which forces us to use a constant mock - // which makes testing anything that is dependent on `oncePerServer` confusing. - // so createMockServer reuses the same 'instance' of the server and overwrites - // the properties to contain different values - const createMockServer = (function() { - const getUserStub = jest.fn(); - let mockConfig; - - const mockServer = { - expose() {}, - config() { - return { - get(key) { - return mockConfig[key]; - }, - }; - }, - log: function() {}, - plugins: { - xpack_main: {}, - security: { getUser: getUserStub }, - }, + const createMockConfig = (mockConfig = {}) => { + return { + get: (...keys) => mockConfig[keys.join('.')], + kbnConfig: { get: (...keys) => mockConfig[keys.join('.')] }, }; + }; + const createMockPlugins = (function() { + const getUserStub = jest.fn(); return function({ securityEnabled = true, xpackInfoUndefined = false, xpackInfoAvailable = true, + getCurrentUser = undefined, user = undefined, - config = {}, }) { - mockConfig = config; - - mockServer.plugins.xpack_main = { - info: !xpackInfoUndefined && { - isAvailable: () => xpackInfoAvailable, - feature(featureName) { - if (featureName === 'security') { - return { - isEnabled: () => securityEnabled, - isAvailable: () => xpackInfoAvailable, - }; + getUserStub.mockReset(); + getUserStub.mockResolvedValue(user); + return { + security: securityEnabled + ? { + authc: { getCurrentUser }, } + : null, + __LEGACY: { + plugins: { + xpack_main: { + info: !xpackInfoUndefined && { + isAvailable: () => xpackInfoAvailable, + feature(featureName) { + if (featureName === 'security') { + return { + isEnabled: () => securityEnabled, + isAvailable: () => xpackInfoAvailable, + }; + } + }, + }, + }, }, }, }; - - getUserStub.mockReset(); - getUserStub.mockResolvedValue(user); - return mockServer; }; })(); @@ -75,10 +67,6 @@ describe('authorized_user_pre_routing', function() { raw: { req: mockRequestRaw }, }); - const getMockPlugins = pluginSet => { - return pluginSet || { security: null }; - }; - const getMockLogger = () => ({ warn: jest.fn(), error: msg => { @@ -87,11 +75,9 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom notFound when xpackInfo is undefined', async function() { - const mockServer = createMockServer({ xpackInfoUndefined: true }); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, - getMockPlugins(), + createMockConfig(), + createMockPlugins({ xpackInfoUndefined: true }), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -100,11 +86,9 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom notFound when xpackInfo isn't available`, async function() { - const mockServer = createMockServer({ xpackInfoAvailable: false }); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, - getMockPlugins(), + createMockConfig(), + createMockPlugins({ xpackInfoAvailable: false }), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -113,11 +97,9 @@ describe('authorized_user_pre_routing', function() { }); it('should return with null user when security is disabled in Elasticsearch', async function() { - const mockServer = createMockServer({ securityEnabled: false }); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, - getMockPlugins(), + createMockConfig(), + createMockPlugins({ securityEnabled: false }), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -125,16 +107,14 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom unauthenticated when security is enabled but no authenticated user', async function() { - const mockServer = createMockServer({ + const mockPlugins = createMockPlugins({ user: null, config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, }); - const mockPlugins = getMockPlugins({ - security: { authc: { getCurrentUser: () => null } }, - }); + mockPlugins.security = { authc: { getCurrentUser: () => null } }; const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + createMockConfig(), mockPlugins, getMockLogger() ); @@ -144,16 +124,14 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom forbidden when security is enabled but user doesn't have allowed role`, async function() { - const mockServer = createMockServer({ + const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); + const mockPlugins = createMockPlugins({ user: { roles: [] }, - config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, - }); - const mockPlugins = getMockPlugins({ - security: { authc: { getCurrentUser: () => ({ roles: ['something_else'] }) } }, + getCurrentUser: () => ({ roles: ['something_else'] }), }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + mockConfig, mockPlugins, getMockLogger() ); @@ -164,18 +142,14 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has explicitly allowed role', async function() { const user = { roles: ['.reporting_user', 'something_else'] }; - const mockServer = createMockServer({ + const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); + const mockPlugins = createMockPlugins({ user, - config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, - }); - const mockPlugins = getMockPlugins({ - security: { - authc: { getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }) }, - }, + getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }), }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + mockConfig, mockPlugins, getMockLogger() ); @@ -185,16 +159,13 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has superuser role', async function() { const user = { roles: ['superuser', 'something_else'] }; - const mockServer = createMockServer({ - user, - config: { 'xpack.reporting.roles.allow': [] }, - }); - const mockPlugins = getMockPlugins({ - security: { authc: { getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }) } }, + const mockConfig = createMockConfig({ 'roles.allow': [] }); + const mockPlugins = createMockPlugins({ + getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }), }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + mockConfig, mockPlugins, getMockLogger() ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts index c5f8c78016f61..1ca28ca62a7f2 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts @@ -7,7 +7,8 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; import { AuthenticatedUser } from '../../../../../../plugins/security/server'; -import { Logger, ServerFacade } from '../../../types'; +import { ReportingConfig } from '../../../server'; +import { Logger } from '../../../types'; import { getUserFactory } from '../../lib/get_user'; import { ReportingSetupDeps } from '../../types'; @@ -18,16 +19,14 @@ export type PreRoutingFunction = ( ) => Promise | AuthenticatedUser | null>; export const authorizedUserPreRoutingFactory = function authorizedUserPreRoutingFn( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ) { - const getUser = getUserFactory(server, plugins.security); - const config = server.config(); + const getUser = getUserFactory(plugins.security, logger); + const { info: xpackInfo } = plugins.__LEGACY.plugins.xpack_main; return async function authorizedUserPreRouting(request: Legacy.Request) { - const xpackInfo = server.plugins.xpack_main.info; - if (!xpackInfo || !xpackInfo.isAvailable()) { logger.warn('Unable to authorize user before xpack info is available.', [ 'authorizedUserPreRouting', @@ -46,10 +45,7 @@ export const authorizedUserPreRoutingFactory = function authorizedUserPreRouting return Boom.unauthorized(`Sorry, you aren't authenticated`); } - const authorizedRoles = [ - superuserRole, - ...(config.get('xpack.reporting.roles.allow') as string[]), - ]; + const authorizedRoles = [superuserRole, ...(config.get('roles', 'allow') as string[])]; if (!user.roles.find(role => authorizedRoles.includes(role))) { return Boom.forbidden(`Sorry, you don't have access to Reporting`); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts index fb3944ea33552..aef37754681ec 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -8,13 +8,7 @@ import contentDisposition from 'content-disposition'; import * as _ from 'lodash'; import { CSV_JOB_TYPE } from '../../../common/constants'; -import { - ExportTypeDefinition, - ExportTypesRegistry, - JobDocOutput, - JobSource, - ServerFacade, -} from '../../../types'; +import { ExportTypeDefinition, ExportTypesRegistry, JobDocOutput, JobSource } from '../../../types'; interface ICustomHeaders { [x: string]: any; @@ -22,9 +16,15 @@ interface ICustomHeaders { type ExportTypeType = ExportTypeDefinition; +interface ErrorFromPayload { + message: string; + reason: string | null; +} + +// A camelCase version of JobDocOutput interface Payload { statusCode: number; - content: any; + content: string | Buffer | ErrorFromPayload; contentType: string; headers: Record; } @@ -48,20 +48,17 @@ const getReportingHeaders = (output: JobDocOutput, exportType: ExportTypeType) = return metaDataHeaders; }; -export function getDocumentPayloadFactory( - server: ServerFacade, - exportTypesRegistry: ExportTypesRegistry -) { - function encodeContent(content: string | null, exportType: ExportTypeType) { +export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegistry) { + function encodeContent(content: string | null, exportType: ExportTypeType): Buffer | string { switch (exportType.jobContentEncoding) { case 'base64': - return content ? Buffer.from(content, 'base64') : content; // Buffer.from rejects null + return content ? Buffer.from(content, 'base64') : ''; // convert null to empty string default: - return content; + return content ? content : ''; // convert null to empty string } } - function getCompleted(output: JobDocOutput, jobType: string, title: string) { + function getCompleted(output: JobDocOutput, jobType: string, title: string): Payload { const exportType = exportTypesRegistry.get((item: ExportTypeType) => item.jobType === jobType); const filename = getTitle(exportType, title); const headers = getReportingHeaders(output, exportType); @@ -77,7 +74,7 @@ export function getDocumentPayloadFactory( }; } - function getFailure(output: JobDocOutput) { + function getFailure(output: JobDocOutput): Payload { return { statusCode: 500, content: { diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts index 30627d5b23230..e7e7c866db96a 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -5,11 +5,12 @@ */ import Boom from 'boom'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { ResponseToolkit } from 'hapi'; +import { ElasticsearchServiceSetup } from 'kibana/server'; import { WHITELISTED_JOB_CONTENT_TYPES } from '../../../common/constants'; -import { ExportTypesRegistry, ServerFacade } from '../../../types'; +import { ExportTypesRegistry } from '../../../types'; import { jobsQueryFactory } from '../../lib/jobs_query'; +import { ReportingConfig } from '../../types'; import { getDocumentPayloadFactory } from './get_document_payload'; interface JobResponseHandlerParams { @@ -21,12 +22,12 @@ interface JobResponseHandlerOpts { } export function downloadJobResponseHandlerFactory( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup, exportTypesRegistry: ExportTypesRegistry ) { - const jobsQuery = jobsQueryFactory(server, elasticsearch); - const getDocumentPayload = getDocumentPayloadFactory(server, exportTypesRegistry); + const jobsQuery = jobsQueryFactory(config, elasticsearch); + const getDocumentPayload = getDocumentPayloadFactory(exportTypesRegistry); return function jobResponseHandler( validJobTypes: string[], @@ -70,10 +71,10 @@ export function downloadJobResponseHandlerFactory( } export function deleteJobResponseHandlerFactory( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup ) { - const jobsQuery = jobsQueryFactory(server, elasticsearch); + const jobsQuery = jobsQueryFactory(config, elasticsearch); return async function deleteJobResponseHander( validJobTypes: string[], diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts index 9e618ff1fe40a..8a79566aafae2 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts @@ -6,17 +6,17 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; -import { Logger, ServerFacade } from '../../../types'; -import { ReportingSetupDeps } from '../../types'; +import { Logger } from '../../../types'; +import { ReportingConfig, ReportingSetupDeps } from '../../types'; export type GetReportingFeatureIdFn = (request: Legacy.Request) => string; export const reportingFeaturePreRoutingFactory = function reportingFeaturePreRoutingFn( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ) { - const xpackMainPlugin = server.plugins.xpack_main; + const xpackMainPlugin = plugins.__LEGACY.plugins.xpack_main; const pluginId = 'reporting'; // License checking and enable/disable logic diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts index 3d275d34e2f7d..06f7efaa9dcbb 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts @@ -6,8 +6,8 @@ import Joi from 'joi'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { Logger, ServerFacade } from '../../../types'; -import { ReportingSetupDeps } from '../../types'; +import { Logger } from '../../../types'; +import { ReportingConfig, ReportingSetupDeps } from '../../types'; import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; import { GetReportingFeatureIdFn, @@ -29,12 +29,12 @@ export type GetRouteConfigFactoryFn = ( ) => RouteConfigFactory; export function getRouteConfigFactoryReportingPre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); return (getFeatureId?: GetReportingFeatureIdFn): RouteConfigFactory => { const preRouting: any[] = [{ method: authorizedUserPreRouting, assign: 'user' }]; @@ -50,11 +50,11 @@ export function getRouteConfigFactoryReportingPre( } export function getRouteOptionsCsv( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ) { - const getRouteConfig = getRouteConfigFactoryReportingPre(server, plugins, logger); + const getRouteConfig = getRouteConfigFactoryReportingPre(config, plugins, logger); return { ...getRouteConfig(() => CSV_FROM_SAVEDOBJECT_JOB_TYPE), validate: { @@ -75,12 +75,12 @@ export function getRouteOptionsCsv( } export function getRouteConfigFactoryManagementPre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); const managementPreRouting = reportingFeaturePreRouting(() => 'management'); return (): RouteConfigFactory => { @@ -99,11 +99,11 @@ export function getRouteConfigFactoryManagementPre( // Additionally, the range-request doesn't alleviate any performance issues on the server as the entire // download is loaded into memory. export function getRouteConfigFactoryDownloadPre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'download'], @@ -114,11 +114,11 @@ export function getRouteConfigFactoryDownloadPre( } export function getRouteConfigFactoryDeletePre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'delete'], diff --git a/x-pack/legacy/plugins/reporting/server/types.d.ts b/x-pack/legacy/plugins/reporting/server/types.d.ts index 59b7bc2020ad9..c773e2d556648 100644 --- a/x-pack/legacy/plugins/reporting/server/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/types.d.ts @@ -11,16 +11,17 @@ import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/ import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import { ReportingPluginSpecOptions } from '../types'; +import { ReportingConfig, ReportingConfigType } from './core'; export interface ReportingSetupDeps { elasticsearch: ElasticsearchServiceSetup; security: SecurityPluginSetup; usageCollection: UsageCollectionSetup; + reporting: { config: ReportingConfig }; __LEGACY: LegacySetup; } export interface ReportingStartDeps { - elasticsearch: ElasticsearchServiceSetup; data: DataPluginStart; __LEGACY: LegacySetup; } @@ -30,10 +31,7 @@ export type ReportingSetup = object; export type ReportingStart = object; export interface LegacySetup { - config: Legacy.Server['config']; - info: Legacy.Server['info']; plugins: { - elasticsearch: Legacy.Server['plugins']['elasticsearch']; xpack_main: XPackMainPlugin & { status?: any; }; @@ -42,4 +40,7 @@ export interface LegacySetup { route: Legacy.Server['route']; } -export { ReportingCore } from './core'; +export { ReportingConfig, ReportingCore } from './core'; + +export type CaptureConfig = ReportingConfigType['capture']; +export type ScrollConfig = ReportingConfigType['csv']['scroll']; diff --git a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts index bd2d0cb835a79..5f12f2b7f044d 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts @@ -5,7 +5,10 @@ */ import { get } from 'lodash'; -import { ServerFacade, ExportTypesRegistry, ESCallCluster } from '../../types'; +import { ESCallCluster, ExportTypesRegistry } from '../../types'; +import { ReportingConfig, ReportingSetupDeps } from '../types'; +import { decorateRangeStats } from './decorate_range_stats'; +import { getExportTypesHandler } from './get_export_type_handler'; import { AggregationBuckets, AggregationResults, @@ -15,8 +18,6 @@ import { RangeAggregationResults, RangeStats, } from './types'; -import { decorateRangeStats } from './decorate_range_stats'; -import { getExportTypesHandler } from './get_export_type_handler'; const JOB_TYPES_KEY = 'jobTypes'; const JOB_TYPES_FIELD = 'jobtype'; @@ -79,10 +80,7 @@ type RangeStatSets = Partial< last7Days: RangeStats; } >; -async function handleResponse( - server: ServerFacade, - response: AggregationResults -): Promise { +async function handleResponse(response: AggregationResults): Promise { const buckets = get(response, 'aggregations.ranges.buckets'); if (!buckets) { return {}; @@ -101,12 +99,12 @@ async function handleResponse( } export async function getReportingUsage( - server: ServerFacade, + config: ReportingConfig, + plugins: ReportingSetupDeps, callCluster: ESCallCluster, exportTypesRegistry: ExportTypesRegistry ) { - const config = server.config(); - const reportingIndex = config.get('xpack.reporting.index'); + const reportingIndex = config.get('index'); const params = { index: `${reportingIndex}-*`, @@ -139,16 +137,18 @@ export async function getReportingUsage( }, }; + const { info: xpackMainInfo } = plugins.__LEGACY.plugins.xpack_main; return callCluster('search', params) - .then((response: AggregationResults) => handleResponse(server, response)) + .then((response: AggregationResults) => handleResponse(response)) .then((usage: RangeStatSets) => { // Allow this to explicitly throw an exception if/when this config is deprecated, // because we shouldn't collect browserType in that case! - const browserType = config.get('xpack.reporting.capture.browser.type'); + const browserType = config.get('capture', 'browser', 'type'); - const xpackInfo = server.plugins.xpack_main.info; const exportTypesHandler = getExportTypesHandler(exportTypesRegistry); - const availability = exportTypesHandler.getAvailability(xpackInfo) as FeatureAvailabilityMap; + const availability = exportTypesHandler.getAvailability( + xpackMainInfo + ) as FeatureAvailabilityMap; const { lastDay, last7Days, ...all } = usage; diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js index a6d753f9b107a..905d2fe9b995c 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js @@ -24,62 +24,52 @@ function getMockUsageCollection() { makeUsageCollector: options => { return new MockUsageCollector(this, options); }, + registerCollector: sinon.stub(), }; } -function getServerMock(customization) { - const getLicenseCheckResults = sinon.stub().returns({}); - const defaultServerMock = { - plugins: { - security: { - isAuthenticated: sinon.stub().returns(true), - }, - xpack_main: { - info: { - isAvailable: sinon.stub().returns(true), - feature: () => ({ - getLicenseCheckResults, - }), - license: { - isOneOf: sinon.stub().returns(false), - getType: sinon.stub().returns('platinum'), - }, - toJSON: () => ({ b: 1 }), - }, +function getPluginsMock( + { license, usageCollection = getMockUsageCollection() } = { license: 'platinum' } +) { + const mockXpackMain = { + info: { + isAvailable: sinon.stub().returns(true), + feature: () => ({ + getLicenseCheckResults: sinon.stub(), + }), + license: { + isOneOf: sinon.stub().returns(false), + getType: sinon.stub().returns(license), }, + toJSON: () => ({ b: 1 }), }, - log: () => {}, - config: () => ({ - get: key => { - if (key === 'xpack.reporting.enabled') { - return true; - } else if (key === 'xpack.reporting.index') { - return '.reporting-index'; - } + }; + return { + usageCollection, + __LEGACY: { + plugins: { + xpack_main: mockXpackMain, }, - }), + }, }; - return Object.assign(defaultServerMock, customization); } const getResponseMock = (customization = {}) => customization; describe('license checks', () => { + let mockConfig; + beforeAll(async () => { + const mockReporting = await createMockReportingCore(); + mockConfig = await mockReporting.getConfig(); + }); + describe('with a basic license', () => { let usageStats; beforeAll(async () => { - const serverWithBasicLicenseMock = getServerMock(); - serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('basic'); + const plugins = getPluginsMock({ license: 'basic' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithBasicLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -98,18 +88,10 @@ describe('license checks', () => { describe('with no license', () => { let usageStats; beforeAll(async () => { - const serverWithNoLicenseMock = getServerMock(); - serverWithNoLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('none'); + const plugins = getPluginsMock({ license: 'none' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithNoLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -128,18 +110,10 @@ describe('license checks', () => { describe('with platinum license', () => { let usageStats; beforeAll(async () => { - const serverWithPlatinumLicenseMock = getServerMock(); - serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('platinum'); + const plugins = getPluginsMock({ license: 'platinum' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithPlatinumLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -158,18 +132,10 @@ describe('license checks', () => { describe('with no usage data', () => { let usageStats; beforeAll(async () => { - const serverWithBasicLicenseMock = getServerMock(); - serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('basic'); + const plugins = getPluginsMock({ license: 'basic' }); const callClusterMock = jest.fn(() => Promise.resolve({})); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithBasicLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -183,21 +149,11 @@ describe('license checks', () => { }); describe('data modeling', () => { - let getReportingUsage; - beforeAll(async () => { - const usageCollection = getMockUsageCollection(); - const serverWithPlatinumLicenseMock = getServerMock(); - serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('platinum'); - ({ fetch: getReportingUsage } = getReportingUsageCollector( - serverWithPlatinumLicenseMock, - usageCollection, - exportTypesRegistry - )); - }); - test('with normal looking usage data', async () => { + const mockReporting = await createMockReportingCore(); + const mockConfig = await mockReporting.getConfig(); + const plugins = getPluginsMock(); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); const callClusterMock = jest.fn(() => Promise.resolve( getResponseMock({ @@ -320,7 +276,7 @@ describe('data modeling', () => { ) ); - const usageStats = await getReportingUsage(callClusterMock); + const usageStats = await fetch(callClusterMock); expect(usageStats).toMatchInlineSnapshot(` Object { "PNG": Object { @@ -415,20 +371,16 @@ describe('data modeling', () => { }); describe('Ready for collection observable', () => { - let mockReporting; - - beforeEach(async () => { - mockReporting = await createMockReportingCore(); - }); - test('converts observable to promise', async () => { - const serverWithBasicLicenseMock = getServerMock(); + const mockReporting = await createMockReportingCore(); + const mockConfig = await mockReporting.getConfig(); + + const usageCollection = getMockUsageCollection(); const makeCollectorSpy = sinon.spy(); - const usageCollection = { - makeUsageCollector: makeCollectorSpy, - registerCollector: sinon.stub(), - }; - registerReportingUsageCollector(mockReporting, serverWithBasicLicenseMock, usageCollection); + usageCollection.makeUsageCollector = makeCollectorSpy; + + const plugins = getPluginsMock({ usageCollection }); + registerReportingUsageCollector(mockReporting, mockConfig, plugins); const [args] = makeCollectorSpy.firstCall.args; expect(args).toMatchInlineSnapshot(` diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts index 14202530fb6c7..ab4ec3a0edf57 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { KIBANA_REPORTING_TYPE } from '../../common/constants'; -import { ReportingCore } from '../../server'; -import { ESCallCluster, ExportTypesRegistry, ServerFacade } from '../../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../../server/types'; +import { ESCallCluster, ExportTypesRegistry } from '../../types'; import { getReportingUsage } from './get_reporting_usage'; import { RangeStats } from './types'; @@ -15,19 +14,19 @@ import { RangeStats } from './types'; const METATYPE = 'kibana_stats'; /* - * @param {Object} server * @return {Object} kibana usage stats type collection object */ export function getReportingUsageCollector( - server: ServerFacade, - usageCollection: UsageCollectionSetup, + config: ReportingConfig, + plugins: ReportingSetupDeps, exportTypesRegistry: ExportTypesRegistry, isReady: () => Promise ) { + const { usageCollection } = plugins; return usageCollection.makeUsageCollector({ type: KIBANA_REPORTING_TYPE, fetch: (callCluster: ESCallCluster) => - getReportingUsage(server, callCluster, exportTypesRegistry), + getReportingUsage(config, plugins, callCluster, exportTypesRegistry), isReady, /* @@ -52,17 +51,17 @@ export function getReportingUsageCollector( export function registerReportingUsageCollector( reporting: ReportingCore, - server: ServerFacade, - usageCollection: UsageCollectionSetup + config: ReportingConfig, + plugins: ReportingSetupDeps ) { const exportTypesRegistry = reporting.getExportTypesRegistry(); const collectionIsReady = reporting.pluginHasStarted.bind(reporting); const collector = getReportingUsageCollector( - server, - usageCollection, + config, + plugins, exportTypesRegistry, collectionIsReady ); - usageCollection.registerCollector(collector); + plugins.usageCollection.registerCollector(collector); } diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 883276d43e27e..930aa7601b8cb 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -10,7 +10,8 @@ import * as contexts from '../export_types/common/lib/screenshots/constants'; import { ElementsPositionAndAttribute } from '../export_types/common/lib/screenshots/types'; import { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../server/browsers'; import { createDriverFactory } from '../server/browsers/chromium'; -import { BrowserConfig, CaptureConfig, Logger } from '../types'; +import { CaptureConfig } from '../server/types'; +import { Logger } from '../types'; interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock, any[]>; @@ -93,24 +94,34 @@ export const createMockBrowserDriverFactory = async ( logger: Logger, opts: Partial ): Promise => { - const browserConfig = { - inspect: true, - userDataDir: '/usr/data/dir', - viewport: { width: 12, height: 12 }, - disableSandbox: false, - proxy: { enabled: false }, - } as BrowserConfig; + const captureConfig = { + timeouts: { openUrl: 30000, waitForElements: 30000, renderComplete: 30000 }, + browser: { + type: 'chromium', + chromium: { + inspect: false, + disableSandbox: false, + userDataDir: '/usr/data/dir', + viewport: { width: 12, height: 12 }, + proxy: { enabled: false, server: undefined, bypass: undefined }, + }, + autoDownload: false, + inspect: true, + userDataDir: '/usr/data/dir', + viewport: { width: 12, height: 12 }, + disableSandbox: false, + proxy: { enabled: false, server: undefined, bypass: undefined }, + maxScreenshotDimension: undefined, + }, + networkPolicy: { enabled: true, rules: [] }, + viewport: { width: 800, height: 600 }, + loadDelay: 2000, + zoom: 1, + maxAttempts: 1, + } as CaptureConfig; const binaryPath = '/usr/local/share/common/secure/'; - const captureConfig = { networkPolicy: {}, timeouts: {} } as CaptureConfig; - - const mockBrowserDriverFactory = await createDriverFactory( - binaryPath, - logger, - browserConfig, - captureConfig - ); - + const mockBrowserDriverFactory = await createDriverFactory(binaryPath, logger, captureConfig); const mockPage = {} as Page; const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts index 0250e6c0a9afd..be60b56dcc0c1 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createLayout } from '../export_types/common/layouts'; import { LayoutTypes } from '../export_types/common/constants'; +import { createLayout } from '../export_types/common/layouts'; import { LayoutInstance } from '../export_types/common/layouts/layout'; -import { ServerFacade } from '../types'; +import { CaptureConfig } from '../server/types'; -export const createMockLayoutInstance = (__LEGACY: ServerFacade) => { - const mockLayout = createLayout(__LEGACY, { +export const createMockLayoutInstance = (captureConfig: CaptureConfig) => { + const mockLayout = createLayout(captureConfig, { id: LayoutTypes.PRESERVE_LAYOUT, dimensions: { height: 12, width: 12 }, }) as LayoutInstance; diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts index 2cd129d47b3f9..332b37b58cb7d 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts @@ -19,16 +19,24 @@ import { coreMock } from 'src/core/server/mocks'; import { ReportingPlugin, ReportingCore } from '../server'; import { ReportingSetupDeps, ReportingStartDeps } from '../server/types'; -export const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => ({ - elasticsearch: setupMock.elasticsearch, - security: setupMock.security, - usageCollection: {} as any, - __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, -}); +const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => { + const configGetStub = jest.fn(); + return { + elasticsearch: setupMock.elasticsearch, + security: setupMock.security, + usageCollection: {} as any, + reporting: { + config: { + get: configGetStub, + kbnConfig: { get: configGetStub }, + }, + }, + __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, + }; +}; export const createMockStartDeps = (startMock?: any): ReportingStartDeps => ({ data: startMock.data, - elasticsearch: startMock.elasticsearch, __LEGACY: {} as any, }); diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts index bb7851ba036a9..531e1dcaf84e0 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts @@ -3,36 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { memoize } from 'lodash'; -import { ServerFacade } from '../types'; - -export const createMockServer = ({ settings = {} }: any): ServerFacade => { - const mockServer = { - config: memoize(() => ({ get: jest.fn() })), - info: { - protocol: 'http', - }, - plugins: { - elasticsearch: { - getCluster: memoize(() => { - return { - callWithRequest: jest.fn(), - }; - }), - }, - }, - }; - const defaultSettings: any = { - 'xpack.reporting.encryptionKey': 'testencryptionkey', - 'server.basePath': '/sbp', - 'server.host': 'localhost', - 'server.port': 5601, - 'xpack.reporting.kibanaServer': {}, - }; - mockServer.config().get.mockImplementation((key: any) => { - return key in settings ? settings[key] : defaultSettings[key]; - }); +import { ServerFacade } from '../types'; - return (mockServer as unknown) as ServerFacade; +export const createMockServer = (): ServerFacade => { + const mockServer = {}; + return mockServer as any; }; diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 238079ba92a29..76253752be1b7 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -7,14 +7,11 @@ import { EventEmitter } from 'events'; import { ResponseObject } from 'hapi'; import { Legacy } from 'kibana'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { CallCluster } from '../../../../src/legacy/core_plugins/elasticsearch'; import { CancellationToken } from './common/cancellation_token'; -import { HeadlessChromiumDriverFactory } from './server/browsers/chromium/driver_factory'; -import { BrowserType } from './server/browsers/types'; -import { LevelLogger } from './server/lib/level_logger'; import { ReportingCore } from './server/core'; -import { LegacySetup, ReportingStartDeps, ReportingSetup, ReportingStart } from './server/types'; +import { LevelLogger } from './server/lib/level_logger'; +import { LegacySetup } from './server/types'; export type Job = EventEmitter & { id: string; @@ -25,8 +22,8 @@ export type Job = EventEmitter & { export interface NetworkPolicyRule { allow: boolean; - protocol: string; - host: string; + protocol?: string; + host?: string; } export interface NetworkPolicy { @@ -93,51 +90,6 @@ export type ReportingResponseToolkit = Legacy.ResponseToolkit; export type ESCallCluster = CallCluster; -/* - * Reporting Config - */ - -export interface CaptureConfig { - browser: { - type: BrowserType; - autoDownload: boolean; - chromium: BrowserConfig; - }; - maxAttempts: number; - networkPolicy: NetworkPolicy; - loadDelay: number; - timeouts: { - openUrl: number; - waitForElements: number; - renderComplet: number; - }; -} - -export interface BrowserConfig { - inspect: boolean; - userDataDir: string; - viewport: { width: number; height: number }; - disableSandbox: boolean; - proxy: { - enabled: boolean; - server: string; - bypass?: string[]; - }; -} - -export interface QueueConfig { - indexInterval: string; - pollEnabled: boolean; - pollInterval: number; - pollIntervalErrorMultiplier: number; - timeout: number; -} - -export interface ScrollConfig { - duration: string; - size: number; -} - export interface ElementPosition { boundingClientRect: { // modern browsers support x/y, but older ones don't @@ -274,14 +226,10 @@ export interface ESQueueInstance { export type CreateJobFactory = ( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger -) => CreateJobFnType; +) => Promise; export type ExecuteJobFactory = ( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger ) => Promise; diff --git a/x-pack/plugins/reporting/config.ts b/x-pack/plugins/reporting/config.ts deleted file mode 100644 index f1d6b1a8f248f..0000000000000 --- a/x-pack/plugins/reporting/config.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export const reportingPollConfig = { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, -}; diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index a7e2bd288f0b1..d330eb9b7872a 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -1,7 +1,11 @@ { + "configPath": [ "xpack", "reporting" ], "id": "reporting", "version": "8.0.0", "kibanaVersion": "kibana", + "optionalPlugins": [ + "usageCollection" + ], "requiredPlugins": [ "home", "management", @@ -11,6 +15,6 @@ "share", "kibanaLegacy" ], - "server": false, + "server": true, "ui": true } diff --git a/x-pack/plugins/reporting/server/config/index.test.ts b/x-pack/plugins/reporting/server/config/index.test.ts new file mode 100644 index 0000000000000..08fe2c5861311 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/index.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as Rx from 'rxjs'; +import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; +import { createConfig$ } from './'; + +interface KibanaServer { + host?: string; + port?: number; + protocol?: string; +} +interface ReportingKibanaServer { + hostname?: string; + port?: number; + protocol?: string; +} + +const makeMockInitContext = (config: { + encryptionKey?: string; + kibanaServer: ReportingKibanaServer; +}): PluginInitializerContext => + ({ + config: { create: () => Rx.of(config) }, + } as PluginInitializerContext); + +const makeMockCoreSetup = (serverInfo: KibanaServer): CoreSetup => + ({ http: { getServerInfo: () => serverInfo } } as any); + +describe('Reporting server createConfig$', () => { + let mockCoreSetup: CoreSetup; + let mockInitContext: PluginInitializerContext; + let mockLogger: Logger; + + beforeEach(() => { + mockCoreSetup = makeMockCoreSetup({ host: 'kibanaHost', port: 5601, protocol: 'http' }); + mockInitContext = makeMockInitContext({ + kibanaServer: {}, + }); + mockLogger = ({ warn: jest.fn() } as unknown) as Logger; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('creates random encryption key and default config using host, protocol, and port from server info', async () => { + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.encryptionKey).toMatch(/\S{32,}/); + expect(result.kibanaServer).toMatchInlineSnapshot(` + Object { + "hostname": "kibanaHost", + "port": 5601, + "protocol": "http", + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(1); + expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ + 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.reporting.encryptionKey in kibana.yml', + ]); + }); + + it('uses the encryption key', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', + kibanaServer: {}, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.encryptionKey).toMatch('iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii'); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('uses the encryption key, reporting kibanaServer settings to override server info', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', + kibanaServer: { + hostname: 'reportingHost', + port: 5677, + protocol: 'httpsa', + }, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result).toMatchInlineSnapshot(` + Object { + "encryptionKey": "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", + "kibanaServer": Object { + "hostname": "reportingHost", + "port": 5677, + "protocol": "httpsa", + }, + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('show warning when kibanaServer.hostName === "0"', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'aaaaaaaaaaaaabbbbbbbbbbbbaaaaaaaaa', + kibanaServer: { hostname: '0' }, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.kibanaServer).toMatchInlineSnapshot(` + Object { + "hostname": "0.0.0.0", + "port": 5601, + "protocol": "http", + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(1); + expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ + `Found 'server.host: \"0\" in Kibana configuration. This is incompatible with Reporting. To enable Reporting to work, 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' is being automatically ` + + `to the configuration. You can change the setting to 'server.host: 0.0.0.0' or add 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' in kibana.yml to prevent this message.`, + ]); + }); +}); diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts new file mode 100644 index 0000000000000..ac51b39ae23b4 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n/'; +import { TypeOf } from '@kbn/config-schema'; +import crypto from 'crypto'; +import { map } from 'rxjs/operators'; +import { PluginConfigDescriptor } from 'kibana/server'; +import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; +import { ConfigSchema, ConfigType } from './schema'; + +export function createConfig$(core: CoreSetup, context: PluginInitializerContext, logger: Logger) { + return context.config.create>().pipe( + map(config => { + // encryption key + let encryptionKey = config.encryptionKey; + if (encryptionKey === undefined) { + logger.warn( + i18n.translate('xpack.reporting.serverConfig.randomEncryptionKey', { + defaultMessage: + 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on ' + + 'restart, please set xpack.reporting.encryptionKey in kibana.yml', + }) + ); + encryptionKey = crypto.randomBytes(16).toString('hex'); + } + + const { kibanaServer: reportingServer } = config; + const serverInfo = core.http.getServerInfo(); + + // kibanaServer.hostname, default to server.host, don't allow "0" + let kibanaServerHostname = reportingServer.hostname + ? reportingServer.hostname + : serverInfo.host; + if (kibanaServerHostname === '0') { + logger.warn( + i18n.translate('xpack.reporting.serverConfig.invalidServerHostname', { + defaultMessage: + `Found 'server.host: "0" in Kibana configuration. This is incompatible with Reporting. ` + + `To enable Reporting to work, '{configKey}: 0.0.0.0' is being automatically to the configuration. ` + + `You can change the setting to 'server.host: 0.0.0.0' or add '{configKey}: 0.0.0.0' in kibana.yml to prevent this message.`, + values: { configKey: 'xpack.reporting.kibanaServer.hostname' }, + }) + ); + kibanaServerHostname = '0.0.0.0'; + } + + // kibanaServer.port, default to server.port + const kibanaServerPort = reportingServer.port + ? reportingServer.port + : serverInfo.port; // prettier-ignore + + // kibanaServer.protocol, default to server.protocol + const kibanaServerProtocol = reportingServer.protocol + ? reportingServer.protocol + : serverInfo.protocol; + + return { + ...config, + encryptionKey, + kibanaServer: { + hostname: kibanaServerHostname, + port: kibanaServerPort, + protocol: kibanaServerProtocol, + }, + }; + }) + ); +} + +export const config: PluginConfigDescriptor = { + schema: ConfigSchema, + deprecations: ({ unused }) => [ + unused('capture.browser.chromium.maxScreenshotDimension'), + unused('capture.concurrency'), + unused('capture.settleTime'), + unused('capture.timeout'), + unused('kibanaApp'), + ], +}; + +export { ConfigSchema, ConfigType }; diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts new file mode 100644 index 0000000000000..d8fe6d1ff084a --- /dev/null +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ConfigSchema } from './schema'; + +describe('Reporting Config Schema', () => { + it(`context {"dev":false,"dist":false} produces correct config`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchObject({ + capture: { + browser: { + autoDownload: true, + chromium: { disableSandbox: false, proxy: { enabled: false } }, + type: 'chromium', + }, + loadDelay: 3000, + maxAttempts: 1, + networkPolicy: { + enabled: true, + rules: [ + { allow: true, host: undefined, protocol: 'http:' }, + { allow: true, host: undefined, protocol: 'https:' }, + { allow: true, host: undefined, protocol: 'ws:' }, + { allow: true, host: undefined, protocol: 'wss:' }, + { allow: true, host: undefined, protocol: 'data:' }, + { allow: false, host: undefined, protocol: undefined }, + ], + }, + viewport: { height: 1200, width: 1950 }, + zoom: 2, + }, + csv: { + checkForFormulas: true, + enablePanelActionDownload: true, + maxSizeBytes: 10485760, + scroll: { duration: '30s', size: 500 }, + }, + encryptionKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + index: '.reporting', + kibanaServer: {}, + poll: { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, + }, + queue: { + indexInterval: 'week', + pollEnabled: true, + pollInterval: 3000, + pollIntervalErrorMultiplier: 10, + timeout: 120000, + }, + roles: { allow: ['reporting_user'] }, + }); + }); + it(`context {"dev":false,"dist":true} produces correct config`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchObject({ + capture: { + browser: { + autoDownload: false, + chromium: { disableSandbox: false, inspect: false, proxy: { enabled: false } }, + type: 'chromium', + }, + loadDelay: 3000, + maxAttempts: 3, + networkPolicy: { + enabled: true, + rules: [ + { allow: true, host: undefined, protocol: 'http:' }, + { allow: true, host: undefined, protocol: 'https:' }, + { allow: true, host: undefined, protocol: 'ws:' }, + { allow: true, host: undefined, protocol: 'wss:' }, + { allow: true, host: undefined, protocol: 'data:' }, + { allow: false, host: undefined, protocol: undefined }, + ], + }, + viewport: { height: 1200, width: 1950 }, + zoom: 2, + }, + csv: { + checkForFormulas: true, + enablePanelActionDownload: true, + maxSizeBytes: 10485760, + scroll: { duration: '30s', size: 500 }, + }, + index: '.reporting', + kibanaServer: {}, + poll: { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, + }, + queue: { + indexInterval: 'week', + pollEnabled: true, + pollInterval: 3000, + pollIntervalErrorMultiplier: 10, + timeout: 120000, + }, + roles: { allow: ['reporting_user'] }, + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts new file mode 100644 index 0000000000000..0058b7a5096f0 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import moment from 'moment'; + +const KibanaServerSchema = schema.object({ + hostname: schema.maybe( + schema.string({ + validate(value) { + if (value === '0') { + return 'must not be "0" for the headless browser to correctly resolve the host'; + } + }, + hostname: true, + }) + ), + port: schema.maybe(schema.number()), + protocol: schema.maybe( + schema.string({ + validate(value) { + if (!/^https?$/.test(value)) { + return 'must be "http" or "https"'; + } + }, + }) + ), +}); + +const QueueSchema = schema.object({ + indexInterval: schema.string({ defaultValue: 'week' }), + pollEnabled: schema.boolean({ defaultValue: true }), + pollInterval: schema.number({ defaultValue: 3000 }), + pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), + timeout: schema.number({ defaultValue: moment.duration(2, 'm').asMilliseconds() }), +}); + +const RulesSchema = schema.object({ + allow: schema.boolean(), + host: schema.maybe(schema.string()), + protocol: schema.maybe(schema.string()), +}); + +const CaptureSchema = schema.object({ + timeouts: schema.object({ + openUrl: schema.number({ defaultValue: 30000 }), + waitForElements: schema.number({ defaultValue: 30000 }), + renderComplete: schema.number({ defaultValue: 30000 }), + }), + networkPolicy: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + rules: schema.arrayOf(RulesSchema, { + defaultValue: [ + { host: undefined, allow: true, protocol: 'http:' }, + { host: undefined, allow: true, protocol: 'https:' }, + { host: undefined, allow: true, protocol: 'ws:' }, + { host: undefined, allow: true, protocol: 'wss:' }, + { host: undefined, allow: true, protocol: 'data:' }, + { host: undefined, allow: false, protocol: undefined }, // Default action is to deny! + ], + }), + }), + zoom: schema.number({ defaultValue: 2 }), + viewport: schema.object({ + width: schema.number({ defaultValue: 1950 }), + height: schema.number({ defaultValue: 1200 }), + }), + loadDelay: schema.number({ + defaultValue: moment.duration(3, 's').asMilliseconds(), + }), // TODO: use schema.duration + browser: schema.object({ + autoDownload: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.boolean({ defaultValue: true }) + ), + chromium: schema.object({ + inspect: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.maybe(schema.never()) + ), + disableSandbox: schema.boolean({ defaultValue: false }), + proxy: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + server: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.uri({ scheme: ['http', 'https'] }), + schema.maybe(schema.never()) + ), + bypass: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.arrayOf(schema.string({ hostname: true })), + schema.maybe(schema.never()) + ), + }), + userDataDir: schema.maybe(schema.string()), // FIXME unused? + }), + type: schema.string({ defaultValue: 'chromium' }), + }), + maxAttempts: schema.conditional( + schema.contextRef('dist'), + true, + schema.number({ defaultValue: 3 }), + schema.number({ defaultValue: 1 }) + ), +}); + +const CsvSchema = schema.object({ + checkForFormulas: schema.boolean({ defaultValue: true }), + enablePanelActionDownload: schema.boolean({ defaultValue: true }), + maxSizeBytes: schema.number({ + defaultValue: 1024 * 1024 * 10, // 10MB + }), // TODO: use schema.byteSize + scroll: schema.object({ + duration: schema.string({ + defaultValue: '30s', + validate(value) { + if (!/^[0-9]+(d|h|m|s|ms|micros|nanos)$/.test(value)) { + return 'must be a duration string'; + } + }, + }), + size: schema.number({ defaultValue: 500 }), + }), +}); + +const EncryptionKeySchema = schema.conditional( + schema.contextRef('dist'), + true, + schema.maybe(schema.string({ minLength: 32 })), + schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) +); + +const RolesSchema = schema.object({ + allow: schema.arrayOf(schema.string(), { defaultValue: ['reporting_user'] }), +}); + +const IndexSchema = schema.string({ defaultValue: '.reporting' }); + +const PollSchema = schema.object({ + jobCompletionNotifier: schema.object({ + interval: schema.number({ + defaultValue: moment.duration(10, 's').asMilliseconds(), + }), // TODO: use schema.duration + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + }), + jobsRefresh: schema.object({ + interval: schema.number({ + defaultValue: moment.duration(5, 's').asMilliseconds(), + }), // TODO: use schema.duration + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + }), +}); + +export const ConfigSchema = schema.object({ + kibanaServer: KibanaServerSchema, + queue: QueueSchema, + capture: CaptureSchema, + csv: CsvSchema, + encryptionKey: EncryptionKeySchema, + roles: RolesSchema, + index: IndexSchema, + poll: PollSchema, +}); + +export type ConfigType = TypeOf; diff --git a/x-pack/plugins/reporting/server/index.ts b/x-pack/plugins/reporting/server/index.ts new file mode 100644 index 0000000000000..2b1844cf2e10e --- /dev/null +++ b/x-pack/plugins/reporting/server/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { ReportingPlugin } from './plugin'; + +export { config, ConfigSchema } from './config'; +export { ConfigType, PluginsSetup } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new ReportingPlugin(initializerContext); diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts new file mode 100644 index 0000000000000..53d821cffbb1f --- /dev/null +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigType, createConfig$ } from './config'; + +export interface PluginsSetup { + /** @deprecated */ + __legacy: { + config$: Observable; + }; +} + +export class ReportingPlugin implements Plugin { + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup): Promise { + return { + __legacy: { + config$: createConfig$(core, this.initializerContext, this.log).pipe(first()), + }, + }; + } + + public start() {} + public stop() {} +} + +export { ConfigType }; From e3431752f3a45ce41100201f7c447aa1fb65d439 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 23 Mar 2020 18:09:30 -0500 Subject: [PATCH 05/64] [SIEM] Move Timeline Template field to first step of rule creation (#60840) * Move timeline template to Define step of Rule creation This required a refactor/simplification of the step_define_rule logic to make things work. In retrospect I think that the issue was we were not handling incoming `defaultValues` props well, which was causing local component state to be lost. Now that we're doing a merge and removed a few unneeded local useStates, things are a) working and b) cleaner * Fix Rule details/edit view with updated data We need to fix the other side of the equation to get these to work: the timeline data was moved to a different step during creation, but when viewing on the frontend we split the rule data back into the separate "steps." * Remove unused import * Fix bug in formatDefineStepData I neglected to pass through index in a previous commit. * Update tests now that timeline has movied to a different step * Fix more tests * Update StepRuleDescription snapshots * Fix cypress Rule Creation test Timeline template moved, and so tests broke. * Add unit tests for filterRuleFieldsForType --- .../signal_detection_rules.spec.ts | 10 +- .../siem/cypress/screens/rule_details.ts | 12 +- .../rules/all/__mocks__/mock.ts | 8 +- .../__snapshots__/index.test.tsx.snap | 34 +- .../description_step/index.test.tsx | 9 +- .../step_about_rule/default_value.ts | 5 - .../components/step_about_rule/index.tsx | 10 - .../components/step_about_rule/schema.tsx | 15 - .../components/step_define_rule/index.tsx | 75 ++-- .../components/step_define_rule/schema.tsx | 15 + .../rules/create/helpers.test.ts | 320 +++++++++--------- .../detection_engine/rules/create/helpers.ts | 65 ++-- .../detection_engine/rules/helpers.test.tsx | 36 +- .../pages/detection_engine/rules/helpers.tsx | 36 +- .../pages/detection_engine/rules/types.ts | 6 +- 15 files changed, 308 insertions(+), 348 deletions(-) diff --git a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts index ce73fe1b7c2a5..70e4fb052e172 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts @@ -13,10 +13,10 @@ import { ABOUT_SEVERITY, ABOUT_STEP, ABOUT_TAGS, - ABOUT_TIMELINE, ABOUT_URLS, DEFINITION_CUSTOM_QUERY, DEFINITION_INDEX_PATTERNS, + DEFINITION_TIMELINE, DEFINITION_STEP, RULE_NAME_HEADER, SCHEDULE_LOOPBACK, @@ -170,10 +170,6 @@ describe('Signal detection rules', () => { .eq(ABOUT_RISK) .invoke('text') .should('eql', newRule.riskScore); - cy.get(ABOUT_STEP) - .eq(ABOUT_TIMELINE) - .invoke('text') - .should('eql', 'Default blank timeline'); cy.get(ABOUT_STEP) .eq(ABOUT_URLS) .invoke('text') @@ -202,6 +198,10 @@ describe('Signal detection rules', () => { .eq(DEFINITION_CUSTOM_QUERY) .invoke('text') .should('eql', `${newRule.customQuery} `); + cy.get(DEFINITION_STEP) + .eq(DEFINITION_TIMELINE) + .invoke('text') + .should('eql', 'Default blank timeline'); cy.get(SCHEDULE_STEP) .eq(SCHEDULE_RUNS) diff --git a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts index 6c16735ba5f24..06e535b37708c 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ABOUT_FALSE_POSITIVES = 4; +export const ABOUT_FALSE_POSITIVES = 3; -export const ABOUT_MITRE = 5; +export const ABOUT_MITRE = 4; export const ABOUT_RULE_DESCRIPTION = '[data-test-subj=stepAboutRuleDetailsToggleDescriptionText]'; @@ -16,14 +16,14 @@ export const ABOUT_SEVERITY = 0; export const ABOUT_STEP = '[data-test-subj="aboutRule"] .euiDescriptionList__description'; -export const ABOUT_TAGS = 6; +export const ABOUT_TAGS = 5; -export const ABOUT_TIMELINE = 2; - -export const ABOUT_URLS = 3; +export const ABOUT_URLS = 2; export const DEFINITION_CUSTOM_QUERY = 1; +export const DEFINITION_TIMELINE = 3; + export const DEFINITION_INDEX_PATTERNS = '[data-test-subj=definitionRule] [data-test-subj="listItemColumnStepRuleDescription"] .euiDescriptionList__description .euiBadge__text'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts index 011a2614c1af9..a6aefefedd5c3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -155,10 +155,6 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ references: ['www.test.co'], falsePositives: ['test'], tags: ['tag1', 'tag2'], - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: 'Titled timeline', - }, threat: [ { framework: 'mockFramework', @@ -186,6 +182,10 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ machineLearningJobId: '', index: ['filebeat-'], queryBar: mockQueryBar, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Titled timeline', + }, }); export const mockScheduleStepRule = (isNew = false, enabled = false): ScheduleStepRule => ({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap index 4d416e70a096c..9a534297e5e29 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap @@ -27,21 +27,6 @@ exports[`description_step StepRuleDescriptionComponent renders correctly against "description": 21, "title": "Risk score", }, - Object { - "description": "Titled timeline", - "title": "Timeline template", - }, - ] - } - /> - - - , "title": "Reference URLs", }, + ] + } + /> + + + { test('returns expected ListItems array when given valid inputs', () => { const result: ListItems[] = buildListItems(mockAboutStep, schema, mockFilterManager); - expect(result.length).toEqual(10); + expect(result.length).toEqual(9); }); }); @@ -431,10 +431,11 @@ describe('description_step', () => { describe('timeline', () => { test('returns timeline title if one exists', () => { + const mockDefineStep = mockDefineStepRule(); const result: ListItems[] = getDescriptionItem( 'timeline', 'Timeline label', - mockAboutStep, + mockDefineStep, mockFilterManager ); @@ -444,7 +445,7 @@ describe('description_step', () => { test('returns default timeline title if none exists', () => { const mockStep = { - ...mockAboutStep, + ...mockDefineStepRule(), timeline: { id: '12345', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts index 417133f230610..52b0038507b59 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts @@ -5,7 +5,6 @@ */ import { AboutStepRule } from '../../types'; -import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; export const threatDefault = [ { @@ -24,10 +23,6 @@ export const stepAboutDefaultValue: AboutStepRule = { references: [''], falsePositives: [''], tags: [], - timeline: { - id: null, - title: DEFAULT_TIMELINE_TITLE, - }, threat: threatDefault, note: '', }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index bfb123f3f3204..58b6ca54f5bbd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -37,7 +37,6 @@ import { stepAboutDefaultValue } from './default_value'; import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; -import { PickTimeline } from '../pick_timeline'; import { StepContentWrapper } from '../step_content_wrapper'; import { MarkdownEditorForm } from '../../../../../components/markdown_editor/form'; @@ -216,15 +215,6 @@ const StepAboutRuleComponent: FC = ({ buttonContent={AdvancedSettingsAccordionButton} > - { - if (defaultValues != null) { - return { - ...defaultValues, - isNew: false, - }; - } else { - return { - ...stepDefineDefaultValue, - index: indicesConfig != null ? indicesConfig : [], - }; - } -}; - const StepDefineRuleComponent: FC = ({ addPadding = false, defaultValues, @@ -106,18 +94,16 @@ const StepDefineRuleComponent: FC = ({ }) => { const mlCapabilities = useContext(MlCapabilitiesContext); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); + const [indexModified, setIndexModified] = useState(false); const [localIsMlRule, setIsMlRule] = useState(false); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); - const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( - defaultValues != null ? defaultValues.index : indicesConfig ?? [] - ); + const [myStepData, setMyStepData] = useState({ + ...stepDefineDefaultValue, + index: indicesConfig ?? [], + }); const [ { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - ] = useFetchIndexPatterns(mylocalIndicesConfig); - const [myStepData, setMyStepData] = useState( - getStepDefaultValue(indicesConfig, null) - ); + ] = useFetchIndexPatterns(myStepData.index); const { form } = useForm({ defaultValue: myStepData, @@ -138,15 +124,13 @@ const StepDefineRuleComponent: FC = ({ }, [form]); useEffect(() => { - if (indicesConfig != null && defaultValues != null) { - const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); - if (!deepEqual(myDefaultValues, myStepData)) { - setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(deepEqual(myDefaultValues.index, indicesConfig)); - setFieldValue(form, schema, myDefaultValues); - } + const { isNew, ...values } = myStepData; + if (defaultValues != null && !deepEqual(values, defaultValues)) { + const newValues = { ...values, ...defaultValues, isNew: false }; + setMyStepData(newValues); + setFieldValue(form, schema, newValues); } - }, [defaultValues, indicesConfig]); + }, [defaultValues, setMyStepData, setFieldValue]); useEffect(() => { if (setForm != null) { @@ -195,7 +179,7 @@ const StepDefineRuleComponent: FC = ({ path="index" config={{ ...schema.index, - labelAppend: !localUseIndicesConfig ? ( + labelAppend: indexModified ? ( {i18n.RESET_DEFAULT_INDEX} @@ -253,17 +237,22 @@ const StepDefineRuleComponent: FC = ({ /> + {({ index, ruleType }) => { if (index != null) { - if (deepEqual(index, indicesConfig) && !localUseIndicesConfig) { - setLocalUseIndicesConfig(true); - } - if (!deepEqual(index, indicesConfig) && localUseIndicesConfig) { - setLocalUseIndicesConfig(false); - } - if (index != null && !isEmpty(index) && !deepEqual(index, mylocalIndicesConfig)) { - setMyLocalIndicesConfig(index); + if (deepEqual(index, indicesConfig) && indexModified) { + setIndexModified(false); + } else if (!deepEqual(index, indicesConfig) && !indexModified) { + setIndexModified(true); } } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index bcfcd4f4ee09d..271c8fabed3a5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -158,4 +158,19 @@ export const schema: FormSchema = { }, ], }, + timeline: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel', + { + defaultMessage: 'Timeline template', + } + ), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateHelpText', + { + defaultMessage: + 'Select an existing timeline to use as a template when investigating generated signals.', + } + ), + }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts index ea6b02924cb3e..dc0459c54adb0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts @@ -19,6 +19,7 @@ import { formatScheduleStepData, formatAboutStepData, formatRule, + filterRuleFieldsForType, } from './helpers'; import { mockDefineStepRule, @@ -88,6 +89,8 @@ describe('helpers', () => { saved_id: 'test123', index: ['filebeat-'], type: 'saved_query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -109,6 +112,119 @@ describe('helpers', () => { index: ['filebeat-'], saved_id: '', type: 'query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { + const mockStepData = { + ...mockData, + }; + delete mockStepData.timeline.id; + + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + id: '', + }, + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + timeline_id: '', + timeline_title: 'Titled timeline', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + }, + }; + delete mockStepData.timeline.title; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + title: '', + }, + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: '', + }; + + expect(result).toEqual(expected); + }); + + test('returns ML fields if type is machine_learning', () => { + const mockStepData: DefineStepRule = { + ...mockData, + ruleType: 'machine_learning', + anomalyThreshold: 44, + machineLearningJobId: 'some_jobert_id', + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + type: 'machine_learning', + anomaly_threshold: 44, + machine_learning_job_id: 'some_jobert_id', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -249,8 +365,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -289,8 +403,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -327,160 +439,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { - const mockStepData = { - ...mockData, - }; - delete mockStepData.timeline.id; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], - }, - ], - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { - const mockStepData = { - ...mockData, - timeline: { - ...mockData.timeline, - id: '', - }, - }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], - }, - ], - timeline_id: '', - timeline_title: 'Titled timeline', - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { - const mockStepData = { - ...mockData, - timeline: { - ...mockData.timeline, - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - }, - }; - delete mockStepData.timeline.title; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], - }, - ], - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { - const mockStepData = { - ...mockData, - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: '', - }, - }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: '1234', name: 'tactic1', reference: 'reference1' }, - technique: [{ id: '456', name: 'technique1', reference: 'technique reference' }], - }, - ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: '', }; expect(result).toEqual(expected); @@ -539,8 +497,6 @@ describe('helpers', () => { technique: [{ id: '456', name: 'technique1', reference: 'technique reference' }], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -583,4 +539,48 @@ describe('helpers', () => { expect(result.id).toBeUndefined(); }); }); + + describe('filterRuleFieldsForType', () => { + let fields: DefineStepRule; + + beforeEach(() => { + fields = mockDefineStepRule(); + }); + + it('removes query fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).not.toHaveProperty('index'); + expect(result).not.toHaveProperty('queryBar'); + }); + + it('leaves ML fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).toHaveProperty('anomalyThreshold'); + expect(result).toHaveProperty('machineLearningJobId'); + }); + + it('leaves arbitrary fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).toHaveProperty('timeline'); + expect(result).toHaveProperty('ruleType'); + }); + + it('removes ML fields if the type is not machine learning', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).not.toHaveProperty('anomalyThreshold'); + expect(result).not.toHaveProperty('machineLearningJobId'); + }); + + it('leaves query fields if the type is query', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).toHaveProperty('index'); + expect(result).toHaveProperty('queryBar'); + }); + + it('leaves arbitrary fields if the type is query', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).toHaveProperty('timeline'); + expect(result).toHaveProperty('ruleType'); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index 1f3379bf681bb..f8900e6a1129e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -64,27 +64,35 @@ export const filterRuleFieldsForType = (fields: T, type: R export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { const ruleFields = filterRuleFieldsForType(defineStepData, defineStepData.ruleType); + const { ruleType, timeline } = ruleFields; + const baseFields = { + type: ruleType, + ...(timeline.id != null && + timeline.title != null && { + timeline_id: timeline.id, + timeline_title: timeline.title, + }), + }; - if (isMlFields(ruleFields)) { - const { anomalyThreshold, machineLearningJobId, isNew, ruleType, ...rest } = ruleFields; - return { - ...rest, - type: ruleType, - anomaly_threshold: anomalyThreshold, - machine_learning_job_id: machineLearningJobId, - }; - } else { - const { queryBar, isNew, ruleType, ...rest } = ruleFields; - return { - ...rest, - type: ruleType, - filters: queryBar?.filters, - language: queryBar?.query?.language, - query: queryBar?.query?.query as string, - saved_id: queryBar?.saved_id, - ...(ruleType === 'query' && queryBar?.saved_id ? { type: 'saved_query' as RuleType } : {}), - }; - } + const typeFields = isMlFields(ruleFields) + ? { + anomaly_threshold: ruleFields.anomalyThreshold, + machine_learning_job_id: ruleFields.machineLearningJobId, + } + : { + index: ruleFields.index, + filters: ruleFields.queryBar?.filters, + language: ruleFields.queryBar?.query?.language, + query: ruleFields.queryBar?.query?.query as string, + saved_id: ruleFields.queryBar?.saved_id, + ...(ruleType === 'query' && + ruleFields.queryBar?.saved_id && { type: 'saved_query' as RuleType }), + }; + + return { + ...baseFields, + ...typeFields, + }; }; export const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRuleJson => { @@ -108,26 +116,11 @@ export const formatScheduleStepData = (scheduleData: ScheduleStepRule): Schedule }; export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => { - const { - falsePositives, - references, - riskScore, - threat, - timeline, - isNew, - note, - ...rest - } = aboutStepData; + const { falsePositives, references, riskScore, threat, isNew, note, ...rest } = aboutStepData; return { false_positives: falsePositives.filter(item => !isEmpty(item)), references: references.filter(item => !isEmpty(item)), risk_score: riskScore, - ...(timeline.id != null && timeline.title != null - ? { - timeline_id: timeline.id, - timeline_title: timeline.title, - } - : {}), threat: threat .filter(singleThreat => singleThreat.tactic.name !== 'none') .map(singleThreat => ({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx index ee43ae5f1d6e2..3224c605192e6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx @@ -65,6 +65,10 @@ describe('rule helpers', () => { ], saved_id: 'test123', }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Titled timeline', + }, }; const aboutRuleStepData = { description: '24/7', @@ -93,10 +97,6 @@ describe('rule helpers', () => { ], }, ], - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: 'Titled timeline', - }, }; const scheduleRuleStepData = { enabled: true, from: '0s', interval: '5m', isNew: false }; const aboutRuleDataDetailsData = { @@ -112,16 +112,6 @@ describe('rule helpers', () => { }); describe('getAboutStepsData', () => { - test('returns timeline id and title of null if they do not exist on rule', () => { - const mockedRule = mockRuleWithEverything('test-id'); - delete mockedRule.timeline_id; - delete mockedRule.timeline_title; - const result: AboutStepRule = getAboutStepsData(mockedRule, false); - - expect(result.timeline.id).toBeNull(); - expect(result.timeline.title).toBeNull(); - }); - test('returns name, description, and note as empty string if detailsView is true', () => { const result: AboutStepRule = getAboutStepsData(mockRuleWithEverything('test-id'), true); @@ -195,6 +185,10 @@ describe('rule helpers', () => { filters: [], saved_id: "Garrett's IP", }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Untitled timeline', + }, }; expect(result).toEqual(expected); @@ -220,10 +214,24 @@ describe('rule helpers', () => { filters: [], saved_id: undefined, }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Untitled timeline', + }, }; expect(result).toEqual(expected); }); + + test('returns timeline id and title of null if they do not exist on rule', () => { + const mockedRule = mockRuleWithEverything('test-id'); + delete mockedRule.timeline_id; + delete mockedRule.timeline_title; + const result: DefineStepRule = getDefineStepsData(mockedRule); + + expect(result.timeline.id).toBeNull(); + expect(result.timeline.title).toBeNull(); + }); }); describe('getHumanizedDuration', () => { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index e59ca5e7e14e5..2ace154482a27 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -42,20 +42,22 @@ export const getStepsData = ({ return { aboutRuleData, modifiedAboutRuleDetailsData, defineRuleData, scheduleRuleData }; }; -export const getDefineStepsData = (rule: Rule): DefineStepRule => { - return { - isNew: false, - ruleType: rule.type, - anomalyThreshold: rule.anomaly_threshold ?? 50, - machineLearningJobId: rule.machine_learning_job_id ?? '', - index: rule.index ?? [], - queryBar: { - query: { query: rule.query ?? '', language: rule.language ?? '' }, - filters: (rule.filters ?? []) as Filter[], - saved_id: rule.saved_id, - }, - }; -}; +export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ + isNew: false, + ruleType: rule.type, + anomalyThreshold: rule.anomaly_threshold ?? 50, + machineLearningJobId: rule.machine_learning_job_id ?? '', + index: rule.index ?? [], + queryBar: { + query: { query: rule.query ?? '', language: rule.language ?? '' }, + filters: (rule.filters ?? []) as Filter[], + saved_id: rule.saved_id, + }, + timeline: { + id: rule.timeline_id ?? null, + title: rule.timeline_title ?? null, + }, +}); export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { const { enabled, interval, from } = rule; @@ -94,8 +96,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu risk_score: riskScore, tags, threat, - timeline_id: timelineId, - timeline_title: timelineTitle, } = rule; return { @@ -109,10 +109,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu riskScore, falsePositives, threat: threat as IMitreEnterpriseAttack[], - timeline: { - id: timelineId ?? null, - title: timelineTitle ?? null, - }, }; }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 447b5dc6325ee..d4caa4639f338 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -57,7 +57,6 @@ export interface AboutStepRule extends StepRuleData { references: string[]; falsePositives: string[]; tags: string[]; - timeline: FieldValueTimeline; threat: IMitreEnterpriseAttack[]; note: string; } @@ -73,6 +72,7 @@ export interface DefineStepRule extends StepRuleData { machineLearningJobId: string; queryBar: FieldValueQueryBar; ruleType: RuleType; + timeline: FieldValueTimeline; } export interface ScheduleStepRule extends StepRuleData { @@ -90,6 +90,8 @@ export interface DefineStepRuleJson { saved_id?: string; query?: string; language?: string; + timeline_id?: string; + timeline_title?: string; type: RuleType; } @@ -101,8 +103,6 @@ export interface AboutStepRuleJson { references: string[]; false_positives: string[]; tags: string[]; - timeline_id?: string; - timeline_title?: string; threat: IMitreEnterpriseAttack[]; note?: string; } From f32a8483bcc5d88b56a9a9aa03466aa2d7457555 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Mon, 23 Mar 2020 16:10:12 -0700 Subject: [PATCH 06/64] Create Painless Lab app (#57538) * Create Painless Playground app (#54578) * Replace heart script with smiley face script. (#57755) * Rename Painless Playground -> Painless Lab. (#57545) * Fix i18n namespace. * Improve smiley face proportions. - Add def keyword to Painless spec. - Temporarily fix broken highlighting. - Add small padding to main controls. * [Painless Lab] Minor Fixes (#58135) * Code restructure, improve types, add plugin id, introduced hook Moved the code execution hook to a custom hook outside of main, also chaining off promise to avoid lower level handling of sequencing. * Re-instated formatting code To improve DX the execution error response from the painless API was massaged to a more reader friendly state, only giving non-repeating information. Currently it is hard to determine the line and character information from the painless endpoint. If the user wishes to see this raw information it will be available in the API response flyout. * Remove leading new line in default script * Remove registration of feature flag * Fix types * Restore previous auto-submit request behaviour * Remove use of null and remove old comment Stick with "undefined" as the designation for something not existing. * [Painless Lab] NP migration (#59794) * Fix sample document editor. * [Painless Lab] Fix float -> integer coercion bug (#60201) * Clarify data and persistence flow. Fix floating point precision bug. * Send a string to API and ES client instead of an object. * Rename helpers lib to format. Add tests for formatRequestPayload. * Add query parameter to score context (#60414) * Fix typo and i18n * Make state init lazy Otherwise we are needlessly reading and JSON.parse'ing on every state update * Support the query parameter in requests to Painless * Fix borked i18n * Fix i18n * Another i18n issue * [Painless] Minor state update model refactor (#60532) * Fix typo and i18n * Make state init lazy Otherwise we are needlessly reading and JSON.parse'ing on every state update * Support the query parameter in requests to Painless * WiP on state refactor * Some cleanup after manual testing * Fix types and i18n * Fix i18n in context_tab * i18n * [Painless] Language Service (#60612) * Added language service * Use the correct monaco instance and add wordwise operations * Remove plugin context initializer for now * [Painless] Replace hard-coded links (#60603) * Replace hard-coded links Also remove all props from Main component * Pass the new links object to the request flyout too * Link directly to painless execute API's contexts * Remove responsive stacking from tabs with icons in them. * Resize Painless Lab bottom bar to accommodate nav drawer width (#60833) * Validate Painless Lab index field (#60841) * Make JSON format of parameters field more prominent. Set default parameters to provide an example to users. * Set default document to provide an example to users. * Simplify context's updateState interface. * Refactor store and context file organization. - Remove common directory, move constants and types files to root. - Move initialState into context file, where it's being used. * Add validation for index input. * Create context directory. * Fix bottom bar z-index. * Position flyout help link so it's bottom-aligned with the title and farther from the close button. Co-authored-by: Matthias Wilhelm Co-authored-by: Jean-Louis Leysens Co-authored-by: Elastic Machine Co-authored-by: Alison Goryachev --- .github/CODEOWNERS | 1 + packages/kbn-ui-shared-deps/monaco.ts | 2 + src/plugins/dev_tools/public/plugin.ts | 2 + x-pack/.i18nrc.json | 1 + .../plugins/painless_lab/common/constants.ts | 15 ++ x-pack/plugins/painless_lab/kibana.json | 16 ++ .../public/application/components/editor.tsx | 35 +++ .../public/application/components/main.tsx | 94 ++++++++ .../application/components/main_controls.tsx | 145 +++++++++++++ .../components/output_pane/context_tab.tsx | 202 +++++++++++++++++ .../components/output_pane/index.ts | 7 + .../components/output_pane/output_pane.tsx | 79 +++++++ .../components/output_pane/output_tab.tsx | 26 +++ .../components/output_pane/parameters_tab.tsx | 87 ++++++++ .../application/components/request_flyout.tsx | 98 +++++++++ .../public/application/constants.tsx | 135 ++++++++++++ .../public/application/context/context.tsx | 95 ++++++++ .../public/application/context/index.tsx | 7 + .../application/context/initial_payload.ts | 22 ++ .../public/application/hooks/index.ts | 7 + .../application/hooks/use_submit_code.ts | 64 ++++++ .../painless_lab/public/application/index.tsx | 46 ++++ .../lib/__snapshots__/format.test.ts.snap | 205 ++++++++++++++++++ .../public/application/lib/format.test.ts | 86 ++++++++ .../public/application/lib/format.ts | 117 ++++++++++ .../painless_lab/public/application/types.ts | 58 +++++ x-pack/plugins/painless_lab/public/index.scss | 1 + x-pack/plugins/painless_lab/public/index.ts | 12 + .../plugins/painless_lab/public/lib/index.ts | 7 + .../public/lib/monaco_painless_lang.ts | 174 +++++++++++++++ x-pack/plugins/painless_lab/public/links.ts | 20 ++ x-pack/plugins/painless_lab/public/plugin.tsx | 114 ++++++++++ .../painless_lab/public/services/index.ts | 7 + .../public/services/language_service.ts | 45 ++++ .../painless_lab/public/styles/_index.scss | 58 +++++ x-pack/plugins/painless_lab/public/types.ts | 15 ++ x-pack/plugins/painless_lab/server/index.ts | 11 + .../plugins/painless_lab/server/lib/index.ts | 7 + .../painless_lab/server/lib/is_es_error.ts | 13 ++ x-pack/plugins/painless_lab/server/plugin.ts | 47 ++++ .../painless_lab/server/routes/api/execute.ts | 46 ++++ .../painless_lab/server/routes/api/index.ts | 7 + .../painless_lab/server/services/index.ts | 7 + .../painless_lab/server/services/license.ts | 82 +++++++ x-pack/plugins/painless_lab/server/types.ts | 17 ++ 45 files changed, 2342 insertions(+) create mode 100644 x-pack/plugins/painless_lab/common/constants.ts create mode 100644 x-pack/plugins/painless_lab/kibana.json create mode 100644 x-pack/plugins/painless_lab/public/application/components/editor.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/main.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/main_controls.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/constants.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/context/context.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/context/index.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/context/initial_payload.ts create mode 100644 x-pack/plugins/painless_lab/public/application/hooks/index.ts create mode 100644 x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts create mode 100644 x-pack/plugins/painless_lab/public/application/index.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap create mode 100644 x-pack/plugins/painless_lab/public/application/lib/format.test.ts create mode 100644 x-pack/plugins/painless_lab/public/application/lib/format.ts create mode 100644 x-pack/plugins/painless_lab/public/application/types.ts create mode 100644 x-pack/plugins/painless_lab/public/index.scss create mode 100644 x-pack/plugins/painless_lab/public/index.ts create mode 100644 x-pack/plugins/painless_lab/public/lib/index.ts create mode 100644 x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts create mode 100644 x-pack/plugins/painless_lab/public/links.ts create mode 100644 x-pack/plugins/painless_lab/public/plugin.tsx create mode 100644 x-pack/plugins/painless_lab/public/services/index.ts create mode 100644 x-pack/plugins/painless_lab/public/services/language_service.ts create mode 100644 x-pack/plugins/painless_lab/public/styles/_index.scss create mode 100644 x-pack/plugins/painless_lab/public/types.ts create mode 100644 x-pack/plugins/painless_lab/server/index.ts create mode 100644 x-pack/plugins/painless_lab/server/lib/index.ts create mode 100644 x-pack/plugins/painless_lab/server/lib/is_es_error.ts create mode 100644 x-pack/plugins/painless_lab/server/plugin.ts create mode 100644 x-pack/plugins/painless_lab/server/routes/api/execute.ts create mode 100644 x-pack/plugins/painless_lab/server/routes/api/index.ts create mode 100644 x-pack/plugins/painless_lab/server/services/index.ts create mode 100644 x-pack/plugins/painless_lab/server/services/license.ts create mode 100644 x-pack/plugins/painless_lab/server/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index df3a56dd35130..2db898fab68bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -181,6 +181,7 @@ /x-pack/plugins/remote_clusters/ @elastic/es-ui /x-pack/legacy/plugins/rollup/ @elastic/es-ui /x-pack/plugins/searchprofiler/ @elastic/es-ui +/x-pack/plugins/painless_lab/ @elastic/es-ui /x-pack/legacy/plugins/snapshot_restore/ @elastic/es-ui /x-pack/legacy/plugins/upgrade_assistant/ @elastic/es-ui /x-pack/plugins/upgrade_assistant/ @elastic/es-ui diff --git a/packages/kbn-ui-shared-deps/monaco.ts b/packages/kbn-ui-shared-deps/monaco.ts index 570aca86c484c..42801c69a3e2c 100644 --- a/packages/kbn-ui-shared-deps/monaco.ts +++ b/packages/kbn-ui-shared-deps/monaco.ts @@ -25,6 +25,8 @@ import 'monaco-editor/esm/vs/base/worker/defaultWorkerFactory'; import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js'; import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js'; +import 'monaco-editor/esm/vs/editor/contrib/wordOperations/wordOperations.js'; // Needed for word-wise char navigation + import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController.js'; // Needed for suggestions import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js'; // Needed for hover import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js'; // Needed for signature diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 9ebfeb5387b26..df61271baf879 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -132,4 +132,6 @@ export class DevToolsPlugin implements Plugin { getSortedDevTools: this.getSortedDevTools.bind(this), }; } + + public stop() {} } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index a8bb989f6bff3..2a28e349ace99 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -30,6 +30,7 @@ "xpack.ml": ["plugins/ml", "legacy/plugins/ml"], "xpack.monitoring": ["plugins/monitoring", "legacy/plugins/monitoring"], "xpack.remoteClusters": "plugins/remote_clusters", + "xpack.painlessLab": "plugins/painless_lab", "xpack.reporting": ["plugins/reporting", "legacy/plugins/reporting"], "xpack.rollupJobs": "legacy/plugins/rollup", "xpack.searchProfiler": "plugins/searchprofiler", diff --git a/x-pack/plugins/painless_lab/common/constants.ts b/x-pack/plugins/painless_lab/common/constants.ts new file mode 100644 index 0000000000000..dfc7d8ae85a2c --- /dev/null +++ b/x-pack/plugins/painless_lab/common/constants.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { LicenseType } from '../../licensing/common/types'; + +const basicLicense: LicenseType = 'basic'; + +export const PLUGIN = { + id: 'painlessLab', + minimumLicenseType: basicLicense, +}; + +export const API_BASE_PATH = '/api/painless_lab'; diff --git a/x-pack/plugins/painless_lab/kibana.json b/x-pack/plugins/painless_lab/kibana.json new file mode 100644 index 0000000000000..4b4ea24202846 --- /dev/null +++ b/x-pack/plugins/painless_lab/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "painlessLab", + "version": "8.0.0", + "kibanaVersion": "kibana", + "requiredPlugins": [ + "devTools", + "licensing", + "home" + ], + "configPath": [ + "xpack", + "painless_lab" + ], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/painless_lab/public/application/components/editor.tsx b/x-pack/plugins/painless_lab/public/application/components/editor.tsx new file mode 100644 index 0000000000000..b8891ce6524f5 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/editor.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { CodeEditor } from '../../../../../../src/plugins/kibana_react/public'; + +interface Props { + code: string; + onChange: (code: string) => void; +} + +export function Editor({ code, onChange }: Props) { + return ( + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/main.tsx b/x-pack/plugins/painless_lab/public/application/components/main.tsx new file mode 100644 index 0000000000000..10907536e9cc2 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/main.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { formatRequestPayload, formatJson } from '../lib/format'; +import { exampleScript } from '../constants'; +import { PayloadFormat } from '../types'; +import { useSubmitCode } from '../hooks'; +import { useAppContext } from '../context'; +import { OutputPane } from './output_pane'; +import { MainControls } from './main_controls'; +import { Editor } from './editor'; +import { RequestFlyout } from './request_flyout'; + +export const Main: React.FunctionComponent = () => { + const { + store: { payload, validation }, + updatePayload, + services: { + http, + chrome: { getIsNavDrawerLocked$ }, + }, + links, + } = useAppContext(); + + const [isRequestFlyoutOpen, setRequestFlyoutOpen] = useState(false); + const { inProgress, response, submit } = useSubmitCode(http); + + // Live-update the output and persist payload state as the user changes it. + useEffect(() => { + if (validation.isValid) { + submit(payload); + } + }, [payload, submit, validation.isValid]); + + const toggleRequestFlyout = () => { + setRequestFlyoutOpen(!isRequestFlyoutOpen); + }; + + const [isNavDrawerLocked, setIsNavDrawerLocked] = useState(false); + + useEffect(() => { + const subscription = getIsNavDrawerLocked$().subscribe((newIsNavDrawerLocked: boolean) => { + setIsNavDrawerLocked(newIsNavDrawerLocked); + }); + + return () => subscription.unsubscribe(); + }); + + return ( +
+ + + +

+ {i18n.translate('xpack.painlessLab.title', { + defaultMessage: 'Painless Lab', + })} +

+
+ + updatePayload({ code: nextCode })} /> +
+ + + + +
+ + updatePayload({ code: exampleScript })} + /> + + {isRequestFlyoutOpen && ( + setRequestFlyoutOpen(false)} + requestBody={formatRequestPayload(payload, PayloadFormat.PRETTY)} + response={response ? formatJson(response.result || response.error) : ''} + /> + )} +
+ ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx b/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx new file mode 100644 index 0000000000000..6307c21e26dc4 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + EuiPopover, + EuiBottomBar, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Links } from '../../links'; + +interface Props { + toggleRequestFlyout: () => void; + isRequestFlyoutOpen: boolean; + isLoading: boolean; + reset: () => void; + links: Links; + isNavDrawerLocked: boolean; +} + +export function MainControls({ + toggleRequestFlyout, + isRequestFlyoutOpen, + reset, + links, + isNavDrawerLocked, +}: Props) { + const [isHelpOpen, setIsHelpOpen] = useState(false); + + const items = [ + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.walkthroughButtonLabel', { + defaultMessage: 'Walkthrough', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.apiReferenceButtonLabel', { + defaultMessage: 'API reference', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.languageSpecButtonLabel', { + defaultMessage: 'Language spec', + })} + , + + { + reset(); + setIsHelpOpen(false); + }} + > + {i18n.translate('xpack.painlessLab.resetButtonLabel', { + defaultMessage: 'Reset script', + })} + , + ]; + + const classes = classNames('painlessLab__bottomBar', { + 'painlessLab__bottomBar-isNavDrawerLocked': isNavDrawerLocked, + }); + + return ( + + + + + + setIsHelpOpen(!isHelpOpen)} + > + {i18n.translate('xpack.painlessLab.helpButtonLabel', { + defaultMessage: 'Help', + })} + + } + isOpen={isHelpOpen} + closePopover={() => setIsHelpOpen(false)} + panelPaddingSize="none" + withTitle + anchorPosition="upRight" + > + + + + + + + + {isRequestFlyoutOpen + ? i18n.translate('xpack.painlessLab.hideRequestButtonLabel', { + defaultMessage: 'Hide API request', + }) + : i18n.translate('xpack.painlessLab.showRequestButtonLabel', { + defaultMessage: 'Show API request', + })} + + + + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx new file mode 100644 index 0000000000000..47efd524f092a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { + EuiFieldText, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, + EuiSuperSelect, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; +import { painlessContextOptions } from '../../constants'; +import { useAppContext } from '../../context'; + +export const ContextTab: FunctionComponent = () => { + const { + store: { payload, validation }, + updatePayload, + links, + } = useAppContext(); + const { context, document, index, query } = payload; + + return ( + <> + + + + {' '} + + + + } + labelAppend={ + + + {i18n.translate('xpack.painlessLab.contextFieldDocLinkText', { + defaultMessage: 'Context docs', + })} + + + } + fullWidth + > + updatePayload({ context: nextContext })} + itemLayoutAlign="top" + hasDividers + fullWidth + /> + + + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + isInvalid={!validation.fields.index} + error={ + validation.fields.index + ? [] + : [ + i18n.translate('xpack.painlessLab.indexFieldMissingErrorMessage', { + defaultMessage: 'Enter an index name', + }), + ] + } + > + { + const nextIndex = e.target.value; + updatePayload({ index: nextIndex }); + }} + isInvalid={!validation.fields.index} + /> + + )} + {/* Query DSL Code Editor */} + {'score'.indexOf(context) !== -1 && ( + + + {' '} + + + + } + labelAppend={ + + + {i18n.translate('xpack.painlessLab.queryFieldDocLinkText', { + defaultMessage: 'Query DSL docs', + })} + + + } + fullWidth + > + + updatePayload({ query: nextQuery })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + )} + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + > + + updatePayload({ document: nextDocument })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + )} + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts b/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts new file mode 100644 index 0000000000000..85b7a7816b5aa --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { OutputPane } from './output_pane'; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx new file mode 100644 index 0000000000000..e6a97bb02f738 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiTabbedContent, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Response } from '../../types'; +import { OutputTab } from './output_tab'; +import { ParametersTab } from './parameters_tab'; +import { ContextTab } from './context_tab'; + +interface Props { + isLoading: boolean; + response?: Response; +} + +export const OutputPane: FunctionComponent = ({ isLoading, response }) => { + const outputTabLabel = ( + + + {isLoading ? ( + + ) : response && response.error ? ( + + ) : ( + + )} + + + + {i18n.translate('xpack.painlessLab.outputTabLabel', { + defaultMessage: 'Output', + })} + + + ); + + return ( + + , + }, + { + id: 'parameters', + name: i18n.translate('xpack.painlessLab.parametersTabLabel', { + defaultMessage: 'Parameters', + }), + content: , + }, + { + id: 'context', + name: i18n.translate('xpack.painlessLab.contextTabLabel', { + defaultMessage: 'Context', + }), + content: , + }, + ]} + /> + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx new file mode 100644 index 0000000000000..8969e5421640a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; + +import { formatResponse } from '../../lib/format'; +import { Response } from '../../types'; + +interface Props { + response?: Response; +} + +export function OutputTab({ response }: Props) { + return ( + <> + + + {formatResponse(response)} + + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx new file mode 100644 index 0000000000000..7c8bce0f7b21b --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent } from 'react'; +import { + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { monaco } from '@kbn/ui-shared-deps/monaco'; +import { i18n } from '@kbn/i18n'; +import { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; + +import { useAppContext } from '../../context'; + +export const ParametersTab: FunctionComponent = () => { + const { + store: { payload }, + updatePayload, + links, + } = useAppContext(); + return ( + <> + + + + {' '} + + + + } + fullWidth + labelAppend={ + + + {i18n.translate('xpack.painlessLab.parametersFieldDocLinkText', { + defaultMessage: 'Parameters docs', + })} + + + } + > + + updatePayload({ parameters: nextParams })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + editorDidMount={(editor: monaco.editor.IStandaloneCodeEditor) => { + // Updating tab size for the editor + const model = editor.getModel(); + if (model) { + model.updateOptions({ tabSize: 2 }); + } + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx b/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx new file mode 100644 index 0000000000000..123df91f4346a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { + EuiCodeBlock, + EuiTabbedContent, + EuiTitle, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Links } from '../../links'; + +interface Props { + onClose: any; + requestBody: string; + links: Links; + response?: string; +} + +export const RequestFlyout: FunctionComponent = ({ + onClose, + requestBody, + response, + links, +}) => { + return ( + + + + + {/* We need an extra div to get out of flex grow */} +
+ +

+ {i18n.translate('xpack.painlessLab.flyoutTitle', { + defaultMessage: 'API request', + })} +

+
+
+
+ + + + {i18n.translate('xpack.painlessLab.flyoutDocLink', { + defaultMessage: 'API documentation', + })} + + +
+
+ + + + {'POST _scripts/painless/_execute\n'} + {requestBody} + + ), + }, + { + id: 'response', + name: 'Response', + content: ( + + {response} + + ), + }, + ]} + /> + +
+ + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/constants.tsx b/x-pack/plugins/painless_lab/public/application/constants.tsx new file mode 100644 index 0000000000000..d8430dbfc7d9d --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/constants.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const defaultLabel = i18n.translate('xpack.painlessLab.contextDefaultLabel', { + defaultMessage: 'Basic', +}); + +const filterLabel = i18n.translate('xpack.painlessLab.contextFilterLabel', { + defaultMessage: 'Filter', +}); + +const scoreLabel = i18n.translate('xpack.painlessLab.contextScoreLabel', { + defaultMessage: 'Score', +}); + +export const painlessContextOptions = [ + { + value: 'painless_test', + inputDisplay: defaultLabel, + dropdownDisplay: ( + <> + {defaultLabel} + +

+ {i18n.translate('xpack.painlessLab.context.defaultLabel', { + defaultMessage: 'The script result will be converted to a string', + })} +

+
+ + ), + }, + { + value: 'filter', + inputDisplay: filterLabel, + dropdownDisplay: ( + <> + {filterLabel} + +

+ {i18n.translate('xpack.painlessLab.context.filterLabel', { + defaultMessage: "Use the context of a filter's script query", + })} +

+
+ + ), + }, + { + value: 'score', + inputDisplay: scoreLabel, + dropdownDisplay: ( + <> + {scoreLabel} + +

+ {i18n.translate('xpack.painlessLab.context.scoreLabel', { + defaultMessage: 'Use the context of a script_score function in function_score query', + })} +

+
+ + ), + }, +]; + +// Render a smiley face as an example. +export const exampleScript = `boolean isInCircle(def posX, def posY, def circleX, def circleY, def radius) { + double distanceFromCircleCenter = Math.sqrt(Math.pow(circleX - posX, 2) + Math.pow(circleY - posY, 2)); + return distanceFromCircleCenter <= radius; +} + +boolean isOnCircle(def posX, def posY, def circleX, def circleY, def radius, def thickness, def squashY) { + double distanceFromCircleCenter = Math.sqrt(Math.pow(circleX - posX, 2) + Math.pow((circleY - posY) / squashY, 2)); + return ( + distanceFromCircleCenter >= radius - thickness + && distanceFromCircleCenter <= radius + thickness + ); +} + +def result = ''; +int charCount = 0; + +// Canvas dimensions +int width = 31; +int height = 31; +double halfWidth = Math.floor(width * 0.5); +double halfHeight = Math.floor(height * 0.5); + +// Style constants +double strokeWidth = 0.6; + +// Smiley face configuration +int headSize = 13; +double headSquashY = 0.78; +int eyePositionX = 10; +int eyePositionY = 12; +int eyeSize = 1; +int mouthSize = 15; +int mouthPositionX = width / 2; +int mouthPositionY = 5; +int mouthOffsetY = 11; + +for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + boolean isHead = isOnCircle(x, y, halfWidth, halfHeight, headSize, strokeWidth, headSquashY); + boolean isLeftEye = isInCircle(x, y, eyePositionX, eyePositionY, eyeSize); + boolean isRightEye = isInCircle(x, y, width - eyePositionX - 1, eyePositionY, eyeSize); + boolean isMouth = isOnCircle(x, y, mouthPositionX, mouthPositionY, mouthSize, strokeWidth, 1) && y > mouthPositionY + mouthOffsetY; + + if (isLeftEye || isRightEye || isMouth || isHead) { + result += "*"; + } else { + result += "."; + } + + result += " "; + + // Make sure the smiley face doesn't deform as the container changes width. + charCount++; + if (charCount % width === 0) { + result += "\\\\n"; + } + } +} + +return result;`; diff --git a/x-pack/plugins/painless_lab/public/application/context/context.tsx b/x-pack/plugins/painless_lab/public/application/context/context.tsx new file mode 100644 index 0000000000000..0fb5842dfea58 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/context.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, ReactNode, useState, useContext } from 'react'; +import { HttpSetup, ChromeStart } from 'src/core/public'; + +import { Links } from '../../links'; +import { Store, Payload, Validation } from '../types'; +import { initialPayload } from './initial_payload'; + +interface AppContextProviderArgs { + children: ReactNode; + value: { + http: HttpSetup; + links: Links; + chrome: ChromeStart; + }; +} + +interface ContextValue { + store: Store; + updatePayload: (changes: Partial) => void; + services: { + http: HttpSetup; + chrome: ChromeStart; + }; + links: Links; +} + +const AppContext = createContext(undefined as any); + +const validatePayload = (payload: Payload): Validation => { + const { index } = payload; + + // For now just validate that the user has entered an index. + const indexExists = Boolean(index || index.trim()); + + return { + isValid: indexExists, + fields: { + index: indexExists, + }, + }; +}; + +export const AppContextProvider = ({ + children, + value: { http, links, chrome }, +}: AppContextProviderArgs) => { + const PAINLESS_LAB_KEY = 'painlessLabState'; + + const [store, setStore] = useState(() => { + // Using a callback here ensures these values are only calculated on the first render. + const defaultPayload = { + ...initialPayload, + ...JSON.parse(localStorage.getItem(PAINLESS_LAB_KEY) || '{}'), + }; + + return { + payload: defaultPayload, + validation: validatePayload(defaultPayload), + }; + }); + + const updatePayload = (changes: Partial): void => { + const nextPayload = { + ...store.payload, + ...changes, + }; + // Persist state locally so we can load it up when the user reopens the app. + localStorage.setItem(PAINLESS_LAB_KEY, JSON.stringify(nextPayload)); + + setStore({ + payload: nextPayload, + validation: validatePayload(nextPayload), + }); + }; + + return ( + + {children} + + ); +}; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error('AppContext can only be used inside of AppContextProvider!'); + } + return ctx; +}; diff --git a/x-pack/plugins/painless_lab/public/application/context/index.tsx b/x-pack/plugins/painless_lab/public/application/context/index.tsx new file mode 100644 index 0000000000000..7a685137b7a4f --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AppContextProvider, useAppContext } from './context'; diff --git a/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts b/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts new file mode 100644 index 0000000000000..4d9d8ad8b3ae7 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { exampleScript, painlessContextOptions } from '../constants'; + +export const initialPayload = { + context: painlessContextOptions[0].value, + code: exampleScript, + parameters: `{ + "string_parameter": "string value", + "number_parameter": 1.5, + "boolean_parameter": true +}`, + index: 'my-index', + document: `{ + "my_field": "field_value" +}`, + query: '', +}; diff --git a/x-pack/plugins/painless_lab/public/application/hooks/index.ts b/x-pack/plugins/painless_lab/public/application/hooks/index.ts new file mode 100644 index 0000000000000..159ff96d2278c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/hooks/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { useSubmitCode } from './use_submit_code'; diff --git a/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts b/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts new file mode 100644 index 0000000000000..36cd4f280ac4c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useRef, useCallback, useState } from 'react'; +import { HttpSetup } from 'kibana/public'; +import { debounce } from 'lodash'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { Response, PayloadFormat, Payload } from '../types'; +import { formatRequestPayload } from '../lib/format'; + +const DEBOUNCE_MS = 800; + +export const useSubmitCode = (http: HttpSetup) => { + const currentRequestIdRef = useRef(0); + const [response, setResponse] = useState(undefined); + const [inProgress, setInProgress] = useState(false); + + const submit = useCallback( + debounce( + async (config: Payload) => { + setInProgress(true); + + // Prevent an older request that resolves after a more recent request from clobbering it. + // We store the resulting ID in this closure for comparison when the request resolves. + const requestId = ++currentRequestIdRef.current; + + try { + const result = await http.post(`${API_BASE_PATH}/execute`, { + // Stringify the string, because http runs it through JSON.parse, and we want to actually + // send a JSON string. + body: JSON.stringify(formatRequestPayload(config, PayloadFormat.UGLY)), + }); + + if (currentRequestIdRef.current === requestId) { + setResponse(result); + setInProgress(false); + } + // else ignore this response... + } catch (error) { + if (currentRequestIdRef.current === requestId) { + setResponse({ + error, + }); + setInProgress(false); + } + // else ignore this response... + } + }, + DEBOUNCE_MS, + { trailing: true } + ), + [http] + ); + + return { + response, + inProgress, + submit, + }; +}; diff --git a/x-pack/plugins/painless_lab/public/application/index.tsx b/x-pack/plugins/painless_lab/public/application/index.tsx new file mode 100644 index 0000000000000..ebcb84bbce83c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/index.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { CoreSetup, CoreStart } from 'kibana/public'; +import { HttpSetup, ChromeStart } from 'src/core/public'; +import { createKibanaReactContext } from '../../../../../src/plugins/kibana_react/public'; + +import { Links } from '../links'; +import { AppContextProvider } from './context'; +import { Main } from './components/main'; + +interface AppDependencies { + http: HttpSetup; + I18nContext: CoreStart['i18n']['Context']; + uiSettings: CoreSetup['uiSettings']; + links: Links; + chrome: ChromeStart; +} + +export function renderApp( + element: HTMLElement | null, + { http, I18nContext, uiSettings, links, chrome }: AppDependencies +) { + if (!element) { + return () => undefined; + } + const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings, + }); + render( + + + +
+ + + , + element + ); + return () => unmountComponentAtNode(element); +} diff --git a/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap b/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap new file mode 100644 index 0000000000000..4df90d1b3abe1 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`formatRequestPayload pretty formats a complex multi-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"// Here's a comment and a variable, then a loop. + double halfWidth = Math.floor(width * 0.5); + for (int y = 0; y < height; y++) { + return \\"results here\\\\\\\\n\\"; + } + + return result;\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload pretty formats a single-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload pretty formats code and parameters 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats code, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\" + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats code, parameters, and context 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"\\", + \\"document\\": + } +}" +`; + +exports[`formatRequestPayload pretty formats code, parameters, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats no script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats a complex multi-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"// Here's a comment and a variable, then a loop.\\\\ndouble halfWidth = Math.floor(width * 0.5);\\\\nfor (int y = 0; y < height; y++) {\\\\n return \\\\\\"results here\\\\\\\\\\\\\\\\n\\\\\\";\\\\n}\\\\n\\\\nreturn result;\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats a single-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats code and parameters 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats code, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\" + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats code, parameters, and context 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"undefined\\", + \\"document\\": undefined + } +}" +`; + +exports[`formatRequestPayload ugly formats code, parameters, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats no script 1`] = ` +"{ + \\"script\\": { + \\"source\\": undefined + } +}" +`; diff --git a/x-pack/plugins/painless_lab/public/application/lib/format.test.ts b/x-pack/plugins/painless_lab/public/application/lib/format.test.ts new file mode 100644 index 0000000000000..5f0022ebbc089 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/format.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PayloadFormat } from '../types'; +import { formatRequestPayload } from './format'; + +describe('formatRequestPayload', () => { + Object.values(PayloadFormat).forEach(format => { + describe(`${format} formats`, () => { + test('no script', () => { + expect(formatRequestPayload({}, format)).toMatchSnapshot(); + }); + + test('a single-line script', () => { + const code = 'return "ok";'; + expect(formatRequestPayload({ code }, format)).toMatchSnapshot(); + }); + + test('a complex multi-line script', () => { + const code = `// Here's a comment and a variable, then a loop. +double halfWidth = Math.floor(width * 0.5); +for (int y = 0; y < height; y++) { + return "results here\\\\n"; +} + +return result;`; + expect(formatRequestPayload({ code }, format)).toMatchSnapshot(); + }); + + test('code and parameters', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + expect(formatRequestPayload({ code, parameters }, format)).toMatchSnapshot(); + }); + + test('code, parameters, and context', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + const context = 'filter'; + expect(formatRequestPayload({ code, parameters, context }, format)).toMatchSnapshot(); + }); + + test('code, context, index, and document', () => { + const code = 'return "ok";'; + const context = 'filter'; + const index = 'index'; + const document = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + expect(formatRequestPayload({ code, context, index, document }, format)).toMatchSnapshot(); + }); + + test('code, parameters, context, index, and document', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + const context = 'filter'; + const index = 'index'; + const document = parameters; + expect( + formatRequestPayload({ code, parameters, context, index, document }, format) + ).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/x-pack/plugins/painless_lab/public/application/lib/format.ts b/x-pack/plugins/painless_lab/public/application/lib/format.ts new file mode 100644 index 0000000000000..15ecdf682d247 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/format.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Response, ExecutionError, PayloadFormat, Payload } from '../types'; + +function prettifyPayload(payload = '', indentationLevel = 0) { + const indentation = new Array(indentationLevel + 1).join(' '); + return payload.replace(/\n/g, `\n${indentation}`); +} + +/** + * Values should be preserved as strings so that floating point precision, + * e.g. 1.0, is preserved instead of being coerced to an integer, e.g. 1. + */ +export function formatRequestPayload( + { code, context, parameters, index, document, query }: Partial, + format: PayloadFormat = PayloadFormat.UGLY +): string { + const isAdvancedContext = context === 'filter' || context === 'score'; + + let formattedCode: string | undefined; + let formattedParameters: string | undefined; + let formattedContext: string | undefined; + let formattedIndex: string | undefined; + let formattedDocument: string | undefined; + let formattedQuery: string | undefined; + + if (format === PayloadFormat.UGLY) { + formattedCode = JSON.stringify(code); + formattedParameters = parameters; + formattedContext = context; + formattedIndex = index; + formattedDocument = document; + formattedQuery = query; + } else { + // Triple quote the code because it's multiline. + formattedCode = `"""${prettifyPayload(code, 4)}"""`; + formattedParameters = prettifyPayload(parameters, 4); + formattedContext = prettifyPayload(context, 6); + formattedIndex = prettifyPayload(index); + formattedDocument = prettifyPayload(document, 4); + formattedQuery = prettifyPayload(query, 4); + } + + const requestPayload = `{ + "script": { + "source": ${formattedCode}${ + parameters + ? `, + "params": ${formattedParameters}` + : `` + } + }${ + isAdvancedContext + ? `, + "context": "${formattedContext}", + "context_setup": { + "index": "${formattedIndex}", + "document": ${formattedDocument}${ + query && context === 'score' + ? `, + "query": ${formattedQuery}` + : '' + } + }` + : `` + } +}`; + return requestPayload; +} + +/** + * Stringify a given object to JSON in a formatted way + */ +export function formatJson(json: unknown): string { + try { + return JSON.stringify(json, null, 2); + } catch (e) { + return `Invalid JSON ${String(json)}`; + } +} + +export function formatResponse(response?: Response): string { + if (!response) { + return ''; + } + if (typeof response.result === 'string') { + return response.result.replace(/\\n/g, '\n'); + } else if (response.error) { + return formatExecutionError(response.error); + } + return formatJson(response); +} + +export function formatExecutionError(executionErrorOrError: ExecutionError | Error): string { + if (executionErrorOrError instanceof Error) { + return executionErrorOrError.message; + } + + if ( + executionErrorOrError.script_stack && + executionErrorOrError.caused_by && + executionErrorOrError.position + ) { + return `Unhandled Exception ${executionErrorOrError.caused_by.type} + +${executionErrorOrError.caused_by.reason} + +Stack: +${formatJson(executionErrorOrError.script_stack)} +`; + } + return formatJson(executionErrorOrError); +} diff --git a/x-pack/plugins/painless_lab/public/application/types.ts b/x-pack/plugins/painless_lab/public/application/types.ts new file mode 100644 index 0000000000000..d800558ef7ecc --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/types.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Store { + payload: Payload; + validation: Validation; +} + +export interface Payload { + context: string; + code: string; + parameters: string; + index: string; + document: string; + query: string; +} + +export interface Validation { + isValid: boolean; + fields: { + index: boolean; + }; +} + +// TODO: This should be an enumerated list +export type Context = string; + +export enum PayloadFormat { + UGLY = 'ugly', + PRETTY = 'pretty', +} + +export interface Response { + error?: ExecutionError | Error; + result?: string; +} + +export type ExecutionErrorScriptStack = string[]; + +export interface ExecutionErrorPosition { + start: number; + end: number; + offset: number; +} + +export interface ExecutionError { + script_stack?: ExecutionErrorScriptStack; + caused_by?: { + type: string; + reason: string; + }; + message?: string; + position: ExecutionErrorPosition; + script: string; +} diff --git a/x-pack/plugins/painless_lab/public/index.scss b/x-pack/plugins/painless_lab/public/index.scss new file mode 100644 index 0000000000000..29a5761255278 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/index.scss @@ -0,0 +1 @@ +@import 'styles/index'; diff --git a/x-pack/plugins/painless_lab/public/index.ts b/x-pack/plugins/painless_lab/public/index.ts new file mode 100644 index 0000000000000..da357e52af676 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './styles/_index.scss'; +import { PainlessLabUIPlugin } from './plugin'; + +export function plugin() { + return new PainlessLabUIPlugin(); +} diff --git a/x-pack/plugins/painless_lab/public/lib/index.ts b/x-pack/plugins/painless_lab/public/lib/index.ts new file mode 100644 index 0000000000000..2421307b7c107 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { monacoPainlessLang } from './monaco_painless_lang'; diff --git a/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts b/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts new file mode 100644 index 0000000000000..602697064a768 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as monaco from 'monaco-editor'; + +/** + * Extends the default type for a Monarch language so we can use + * attribute references (like @keywords to reference the keywords list) + * in the defined tokenizer + */ +interface Language extends monaco.languages.IMonarchLanguage { + default: string; + brackets: any; + keywords: string[]; + symbols: RegExp; + escapes: RegExp; + digits: RegExp; + primitives: string[]; + octaldigits: RegExp; + binarydigits: RegExp; + constants: string[]; + operators: string[]; +} + +export const monacoPainlessLang = { + default: '', + // painless does not use < >, so we define our own + brackets: [ + ['{', '}', 'delimiter.curly'], + ['[', ']', 'delimiter.square'], + ['(', ')', 'delimiter.parenthesis'], + ], + keywords: [ + 'if', + 'in', + 'else', + 'while', + 'do', + 'for', + 'continue', + 'break', + 'return', + 'new', + 'try', + 'catch', + 'throw', + 'this', + 'instanceof', + ], + primitives: ['void', 'boolean', 'byte', 'short', 'char', 'int', 'long', 'float', 'double', 'def'], + constants: ['true', 'false'], + operators: [ + '=', + '>', + '<', + '!', + '~', + '?', + '?:', + '?.', + ':', + '==', + '===', + '<=', + '>=', + '!=', + '!==', + '&&', + '||', + '++', + '--', + '+', + '-', + '*', + '/', + '&', + '|', + '^', + '%', + '<<', + '>>', + '>>>', + '+=', + '-=', + '*=', + '/=', + '&=', + '|=', + '^=', + '%=', + '<<=', + '>>=', + '>>>=', + '->', + '::', + '=~', + '==~', + ], + symbols: /[=>; + +export const getLinks = ({ DOC_LINK_VERSION, ELASTIC_WEBSITE_URL }: DocLinksStart) => + Object.freeze({ + painlessExecuteAPI: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html`, + painlessExecuteAPIContexts: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html#_contexts`, + painlessAPIReference: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-api-reference.html`, + painlessWalkthrough: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-walkthrough.html`, + painlessLangSpec: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, + esQueryDSL: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/query-dsl.html`, + modulesScriptingPreferParams: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/modules-scripting-using.html#prefer-params`, + }); diff --git a/x-pack/plugins/painless_lab/public/plugin.tsx b/x-pack/plugins/painless_lab/public/plugin.tsx new file mode 100644 index 0000000000000..b9ca7031cf670 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/plugin.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { Plugin, CoreStart, CoreSetup } from 'kibana/public'; +import { first } from 'rxjs/operators'; +import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; +import { LICENSE_CHECK_STATE } from '../../licensing/public'; + +import { PLUGIN } from '../common/constants'; + +import { PluginDependencies } from './types'; +import { getLinks } from './links'; +import { LanguageService } from './services'; + +export class PainlessLabUIPlugin implements Plugin { + languageService = new LanguageService(); + + async setup( + { http, getStartServices, uiSettings }: CoreSetup, + { devTools, home, licensing }: PluginDependencies + ) { + home.featureCatalogue.register({ + id: PLUGIN.id, + title: i18n.translate('xpack.painlessLab.registryProviderTitle', { + defaultMessage: 'Painless Lab (beta)', + }), + description: i18n.translate('xpack.painlessLab.registryProviderDescription', { + defaultMessage: 'Simulate and debug painless code.', + }), + icon: '', + path: '/app/kibana#/dev_tools/painless_lab', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + + devTools.register({ + id: 'painless_lab', + order: 7, + title: ( + + + {i18n.translate('xpack.painlessLab.displayName', { + defaultMessage: 'Painless Lab', + })} + + + + + + + ) as any, + enableRouting: false, + disabled: false, + mount: async (ctx, { element }) => { + const [core] = await getStartServices(); + + const { + i18n: { Context: I18nContext }, + notifications, + docLinks, + chrome, + } = core; + + this.languageService.setup(); + + const license = await licensing.license$.pipe(first()).toPromise(); + const { state, message: invalidLicenseMessage } = license.check( + PLUGIN.id, + PLUGIN.minimumLicenseType + ); + const isValidLicense = state === LICENSE_CHECK_STATE.Valid; + + if (!isValidLicense) { + notifications.toasts.addDanger(invalidLicenseMessage as string); + window.location.hash = '/dev_tools'; + return () => {}; + } + + const { renderApp } = await import('./application'); + const tearDownApp = renderApp(element, { + I18nContext, + http, + uiSettings, + links: getLinks(docLinks), + chrome, + }); + + return () => { + tearDownApp(); + }; + }, + }); + } + + async start(core: CoreStart, plugins: any) {} + + async stop() { + this.languageService.stop(); + } +} diff --git a/x-pack/plugins/painless_lab/public/services/index.ts b/x-pack/plugins/painless_lab/public/services/index.ts new file mode 100644 index 0000000000000..20bec9de24550 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LanguageService } from './language_service'; diff --git a/x-pack/plugins/painless_lab/public/services/language_service.ts b/x-pack/plugins/painless_lab/public/services/language_service.ts new file mode 100644 index 0000000000000..efff9cd0e78d5 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/services/language_service.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// It is important that we use this specific monaco instance so that +// editor settings are registered against the instance our React component +// uses. +import { monaco } from '@kbn/ui-shared-deps/monaco'; + +// @ts-ignore +import workerSrc from 'raw-loader!monaco-editor/min/vs/base/worker/workerMain.js'; + +import { monacoPainlessLang } from '../lib'; + +const LANGUAGE_ID = 'painless'; + +// Safely check whether these globals are present +const CAN_CREATE_WORKER = typeof Blob === 'function' && typeof Worker === 'function'; + +export class LanguageService { + private originalMonacoEnvironment: any; + + public setup() { + monaco.languages.register({ id: LANGUAGE_ID }); + monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, monacoPainlessLang); + + if (CAN_CREATE_WORKER) { + this.originalMonacoEnvironment = (window as any).MonacoEnvironment; + (window as any).MonacoEnvironment = { + getWorker: () => { + const blob = new Blob([workerSrc], { type: 'application/javascript' }); + return new Worker(window.URL.createObjectURL(blob)); + }, + }; + } + } + + public stop() { + if (CAN_CREATE_WORKER) { + (window as any).MonacoEnvironment = this.originalMonacoEnvironment; + } + } +} diff --git a/x-pack/plugins/painless_lab/public/styles/_index.scss b/x-pack/plugins/painless_lab/public/styles/_index.scss new file mode 100644 index 0000000000000..f68dbe302511a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/styles/_index.scss @@ -0,0 +1,58 @@ +@import '@elastic/eui/src/components/header/variables'; +@import '@elastic/eui/src/components/nav_drawer/variables'; + +/** + * This is a very brittle way of preventing the editor and other content from disappearing + * behind the bottom bar. + */ +$bottomBarHeight: calc(#{$euiSize} * 3); + +.painlessLabBottomBarPlaceholder { + height: $bottomBarHeight +} + +.painlessLabRightPane { + border-right: none; + border-top: none; + border-bottom: none; + border-radius: 0; + padding-top: 0; + height: 100%; +} + +.painlessLabRightPane__tabs { + display: flex; + flex-direction: column; + height: 100%; + + [role="tabpanel"] { + height: 100%; + overflow-y: auto; + } +} + +.painlessLab__betaLabelContainer { + line-height: 0; +} + +.painlessLabMainContainer { + height: calc(100vh - calc(#{$euiHeaderChildSize} * 2) - #{$bottomBarHeight}); +} + +.painlessLabPanelsContainer { + // The panels container should adopt the height of the main container + height: 100%; +} + +/** + * 1. Hack EUI so the bottom bar doesn't obscure the nav drawer flyout, but is also not obscured + * by the main content area. + */ +.painlessLab__bottomBar { + z-index: 5; /* 1 */ + left: $euiNavDrawerWidthCollapsed; +} + +.painlessLab__bottomBar-isNavDrawerLocked { + left: $euiNavDrawerWidthExpanded; +} diff --git a/x-pack/plugins/painless_lab/public/types.ts b/x-pack/plugins/painless_lab/public/types.ts new file mode 100644 index 0000000000000..9153f4c28de8d --- /dev/null +++ b/x-pack/plugins/painless_lab/public/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { DevToolsSetup } from '../../../../src/plugins/dev_tools/public'; +import { LicensingPluginSetup } from '../../licensing/public'; + +export interface PluginDependencies { + licensing: LicensingPluginSetup; + home: HomePublicPluginSetup; + devTools: DevToolsSetup; +} diff --git a/x-pack/plugins/painless_lab/server/index.ts b/x-pack/plugins/painless_lab/server/index.ts new file mode 100644 index 0000000000000..96ea9a163deca --- /dev/null +++ b/x-pack/plugins/painless_lab/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PluginInitializerContext } from 'kibana/server'; +import { PainlessLabServerPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => { + return new PainlessLabServerPlugin(ctx); +}; diff --git a/x-pack/plugins/painless_lab/server/lib/index.ts b/x-pack/plugins/painless_lab/server/lib/index.ts new file mode 100644 index 0000000000000..a9a3c61472d8c --- /dev/null +++ b/x-pack/plugins/painless_lab/server/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { isEsError } from './is_es_error'; diff --git a/x-pack/plugins/painless_lab/server/lib/is_es_error.ts b/x-pack/plugins/painless_lab/server/lib/is_es_error.ts new file mode 100644 index 0000000000000..4137293cf39c0 --- /dev/null +++ b/x-pack/plugins/painless_lab/server/lib/is_es_error.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as legacyElasticsearch from 'elasticsearch'; + +const esErrorsParent = legacyElasticsearch.errors._Abstract; + +export function isEsError(err: Error) { + return err instanceof esErrorsParent; +} diff --git a/x-pack/plugins/painless_lab/server/plugin.ts b/x-pack/plugins/painless_lab/server/plugin.ts new file mode 100644 index 0000000000000..74629a0b035ed --- /dev/null +++ b/x-pack/plugins/painless_lab/server/plugin.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; + +import { PLUGIN } from '../common/constants'; +import { License } from './services'; +import { Dependencies } from './types'; +import { registerExecuteRoute } from './routes/api'; + +export class PainlessLabServerPlugin implements Plugin { + private readonly license: License; + private readonly logger: Logger; + + constructor({ logger }: PluginInitializerContext) { + this.logger = logger.get(); + this.license = new License(); + } + + async setup({ http }: CoreSetup, { licensing }: Dependencies) { + const router = http.createRouter(); + + this.license.setup( + { + pluginId: PLUGIN.id, + minimumLicenseType: PLUGIN.minimumLicenseType, + defaultErrorMessage: i18n.translate('xpack.painlessLab.licenseCheckErrorMessage', { + defaultMessage: 'License check failed', + }), + }, + { + licensing, + logger: this.logger, + } + ); + + registerExecuteRoute({ router, license: this.license }); + } + + start() {} + + stop() {} +} diff --git a/x-pack/plugins/painless_lab/server/routes/api/execute.ts b/x-pack/plugins/painless_lab/server/routes/api/execute.ts new file mode 100644 index 0000000000000..55adb5e0410cc --- /dev/null +++ b/x-pack/plugins/painless_lab/server/routes/api/execute.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; +import { isEsError } from '../../lib'; + +const bodySchema = schema.string(); + +export function registerExecuteRoute({ router, license }: RouteDependencies) { + router.post( + { + path: `${API_BASE_PATH}/execute`, + validate: { + body: bodySchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const body = req.body; + + try { + const callAsCurrentUser = ctx.core.elasticsearch.dataClient.callAsCurrentUser; + const response = await callAsCurrentUser('scriptsPainlessExecute', { + body, + }); + + return res.ok({ + body: response, + }); + } catch (e) { + if (isEsError(e)) { + // Assume invalid painless script was submitted + // Return 200 with error object + return res.ok({ + body: e.body, + }); + } + return res.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/painless_lab/server/routes/api/index.ts b/x-pack/plugins/painless_lab/server/routes/api/index.ts new file mode 100644 index 0000000000000..62f05971d59cc --- /dev/null +++ b/x-pack/plugins/painless_lab/server/routes/api/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { registerExecuteRoute } from './execute'; diff --git a/x-pack/plugins/painless_lab/server/services/index.ts b/x-pack/plugins/painless_lab/server/services/index.ts new file mode 100644 index 0000000000000..b7a45e59549eb --- /dev/null +++ b/x-pack/plugins/painless_lab/server/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { License } from './license'; diff --git a/x-pack/plugins/painless_lab/server/services/license.ts b/x-pack/plugins/painless_lab/server/services/license.ts new file mode 100644 index 0000000000000..1c9d77198f928 --- /dev/null +++ b/x-pack/plugins/painless_lab/server/services/license.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Logger } from 'src/core/server'; +import { + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, +} from 'kibana/server'; + +import { LicensingPluginSetup } from '../../../licensing/server'; +import { LicenseType, LICENSE_CHECK_STATE } from '../../../licensing/common/types'; + +export interface LicenseStatus { + isValid: boolean; + message?: string; +} + +interface SetupSettings { + pluginId: string; + minimumLicenseType: LicenseType; + defaultErrorMessage: string; +} + +export class License { + private licenseStatus: LicenseStatus = { + isValid: false, + message: 'Invalid License', + }; + + setup( + { pluginId, minimumLicenseType, defaultErrorMessage }: SetupSettings, + { licensing, logger }: { licensing: LicensingPluginSetup; logger: Logger } + ) { + licensing.license$.subscribe(license => { + const { state, message } = license.check(pluginId, minimumLicenseType); + const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid; + + if (hasRequiredLicense) { + this.licenseStatus = { isValid: true }; + } else { + this.licenseStatus = { + isValid: false, + message: message || defaultErrorMessage, + }; + if (message) { + logger.info(message); + } + } + }); + } + + guardApiRoute(handler: RequestHandler) { + const license = this; + + return function licenseCheck( + ctx: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) { + const licenseStatus = license.getStatus(); + + if (!licenseStatus.isValid) { + return response.customError({ + body: { + message: licenseStatus.message || '', + }, + statusCode: 403, + }); + } + + return handler(ctx, request, response); + }; + } + + getStatus() { + return this.licenseStatus; + } +} diff --git a/x-pack/plugins/painless_lab/server/types.ts b/x-pack/plugins/painless_lab/server/types.ts new file mode 100644 index 0000000000000..541a31dd175ec --- /dev/null +++ b/x-pack/plugins/painless_lab/server/types.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter } from 'src/core/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { License } from './services'; + +export interface RouteDependencies { + router: IRouter; + license: License; +} + +export interface Dependencies { + licensing: LicensingPluginSetup; +} From 81b372363367e8fe6085ecaf25b009d84674a1b2 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 23 Mar 2020 19:26:49 -0400 Subject: [PATCH 07/64] [SIEM] [CASES] Build lego blocks case details view (#60864) * modify API to get the total comments in _find + Add user action to track what user are doing + create _pushed api to know when case have been pushed * fix rebase * add connector name in case configuration saved object * fix total comment in all cases * totalComment bug on the API * integrate user action API with UI * fix merged issue * integration APi to push to services with UI * Fix bugs * wip to show pushed service in ui * finish the full flow with pushing to service now * review about client discrepency * clean up + review * merge issue * update error msgs to info * add aria label + fix but on add/remove tags * fix i18n Co-authored-by: Christos Nasikas --- .../components/link_to/redirect_to_case.tsx | 7 +- .../siem/public/components/links/index.tsx | 28 ++- .../components/url_state/index.test.tsx | 2 +- .../siem/public/components/url_state/types.ts | 16 +- .../siem/public/containers/case/api.ts | 64 ++++++ .../public/containers/case/configure/types.ts | 1 + .../case/configure/use_configure.tsx | 27 ++- .../public/containers/case/translations.ts | 7 + .../siem/public/containers/case/types.ts | 34 ++- .../case/use_get_action_license.tsx | 74 ++++++ .../public/containers/case/use_get_case.tsx | 3 +- .../case/use_get_case_user_actions.tsx | 126 ++++++++++ .../case/use_post_push_to_service.tsx | 183 +++++++++++++++ .../containers/case/use_update_case.tsx | 13 +- .../containers/case/use_update_comment.tsx | 12 +- .../siem/public/containers/case/utils.ts | 16 ++ .../case/components/add_comment/index.tsx | 129 ++++++----- .../components/all_cases/__mock__/index.tsx | 19 +- .../case/components/all_cases/columns.tsx | 7 +- .../case/components/all_cases/index.test.tsx | 4 +- .../pages/case/components/all_cases/index.tsx | 20 +- .../case/components/case_status/index.tsx | 85 ++++--- .../components/case_view/__mock__/index.tsx | 24 +- .../case/components/case_view/index.test.tsx | 42 +++- .../pages/case/components/case_view/index.tsx | 118 ++++++++-- .../components/case_view/push_to_service.tsx | 185 +++++++++++++++ .../case/components/case_view/translations.ts | 96 +++++++- .../case/components/configure_cases/index.tsx | 10 +- .../errors_push_service_callout/index.tsx | 33 +++ .../translations.ts | 21 ++ .../components/user_action_tree/helpers.tsx | 75 ++++++ .../components/user_action_tree/index.tsx | 215 ++++++++++++++---- .../user_action_tree/translations.ts | 34 +++ .../user_action_tree/user_action_item.tsx | 134 ++++++++--- .../user_action_tree/user_action_title.tsx | 134 ++++++++--- .../plugins/siem/public/pages/case/index.tsx | 4 + .../siem/public/pages/case/translations.ts | 38 +++- .../plugins/siem/public/pages/case/utils.ts | 4 +- x-pack/plugins/case/common/api/cases/case.ts | 85 ++++++- .../plugins/case/common/api/cases/comment.ts | 2 + .../case/common/api/cases/configure.ts | 1 + x-pack/plugins/case/common/api/cases/index.ts | 1 + .../case/common/api/cases/user_actions.ts | 59 +++++ x-pack/plugins/case/server/plugin.ts | 7 +- .../routes/api/__fixtures__/mock_router.ts | 4 + .../api/__fixtures__/mock_saved_objects.ts | 16 +- .../api/cases/comments/delete_all_comments.ts | 29 ++- .../api/cases/comments/delete_comment.ts | 46 ++-- .../api/cases/comments/find_comments.ts | 5 +- .../api/cases/comments/get_all_comment.ts | 3 +- .../routes/api/cases/comments/get_comment.ts | 11 - .../api/cases/comments/patch_comment.ts | 50 ++-- .../routes/api/cases/comments/post_comment.ts | 37 +-- .../api/cases/configure/patch_configure.ts | 3 +- .../api/cases/configure/post_configure.ts | 3 +- .../server/routes/api/cases/delete_cases.ts | 27 ++- .../server/routes/api/cases/find_cases.ts | 39 +++- .../case/server/routes/api/cases/get_case.ts | 5 +- .../case/server/routes/api/cases/helpers.ts | 56 ++++- .../routes/api/cases/patch_cases.test.ts | 6 +- .../server/routes/api/cases/patch_cases.ts | 23 +- .../case/server/routes/api/cases/post_case.ts | 27 ++- .../case/server/routes/api/cases/push_case.ts | 176 ++++++++++++++ .../api/cases/reporters/get_reporters.ts | 3 +- .../routes/api/cases/status/get_status.ts | 5 +- .../server/routes/api/cases/tags/get_tags.ts | 3 +- .../user_actions/get_all_user_actions.ts | 46 ++++ .../plugins/case/server/routes/api/index.ts | 16 +- .../plugins/case/server/routes/api/types.ts | 12 +- .../plugins/case/server/routes/api/utils.ts | 32 ++- .../case/server/saved_object_types/cases.ts | 39 +++- .../server/saved_object_types/comments.ts | 13 ++ .../server/saved_object_types/configure.ts | 9 + .../case/server/saved_object_types/index.ts | 1 + .../server/saved_object_types/user_actions.ts | 47 ++++ x-pack/plugins/case/server/services/index.ts | 50 +++- .../server/services/user_actions/helpers.ts | 195 ++++++++++++++++ .../server/services/user_actions/index.ts | 77 +++++++ 78 files changed, 2875 insertions(+), 438 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts create mode 100644 x-pack/plugins/case/common/api/cases/user_actions.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/push_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts create mode 100644 x-pack/plugins/case/server/saved_object_types/user_actions.ts create mode 100644 x-pack/plugins/case/server/services/user_actions/helpers.ts create mode 100644 x-pack/plugins/case/server/services/user_actions/index.ts diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx index 3056b166c1153..20ba0b50f5126 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx @@ -31,6 +31,7 @@ export const RedirectToConfigureCasesPage = () => ( const baseCaseUrl = `#/link-to/${SiemPageName.case}`; export const getCaseUrl = () => baseCaseUrl; -export const getCaseDetailsUrl = (detailName: string) => `${baseCaseUrl}/${detailName}`; -export const getCreateCaseUrl = () => `${baseCaseUrl}/create`; -export const getConfigureCasesUrl = () => `${baseCaseUrl}/configure`; +export const getCaseDetailsUrl = (detailName: string, search: string) => + `${baseCaseUrl}/${detailName}${search}`; +export const getCreateCaseUrl = (search: string) => `${baseCaseUrl}/create${search}`; +export const getConfigureCasesUrl = (search: string) => `${baseCaseUrl}/configure${search}`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 04de0b1d5d3bf..935df9ad3361f 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -23,8 +23,10 @@ import { import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types'; import { useUiSetting$ } from '../../lib/kibana'; import { IP_REPUTATION_LINKS_SETTING } from '../../../common/constants'; +import { navTabs } from '../../pages/home/home_navigations'; import * as i18n from '../page/network/ip_overview/translations'; import { isUrlInvalid } from '../../pages/detection_engine/rules/components/step_about_rule/helpers'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { ExternalLinkIcon } from '../external_link_icon'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -89,20 +91,24 @@ export const IPDetailsLink = React.memo(IPDetailsLinkComponent); const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailName: string }> = ({ children, detailName, -}) => ( - - {children ? children : detailName} - -); +}) => { + const urlSearch = useGetUrlSearch(navTabs.case); + return ( + + {children ? children : detailName} + + ); +}; export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; -export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => ( - {children} -)); +export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => { + const urlSearch = useGetUrlSearch(navTabs.case); + return {children}; +}); CreateCaseLink.displayName = 'CreateCaseLink'; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx index 10aa388449d91..6e957313d9b04 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx @@ -158,7 +158,7 @@ describe('UrlStateContainer', () => { hash: '', pathname: examplePath, search: [CONSTANTS.timelinePage].includes(page) - ? '?timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))' + ? `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))` : `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, state: '', }); diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index 2cb1b0c96ad79..c6f49d8a0e49b 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -60,8 +60,20 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], - timeline: [CONSTANTS.timeline, CONSTANTS.timerange], - case: [], + timeline: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timeline, + CONSTANTS.timerange, + ], + case: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timeline, + CONSTANTS.timerange, + ], }; export type LocationTypes = diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 5ba1f010e0d52..16ee294224bb9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -13,9 +13,15 @@ import { CommentRequest, CommentResponse, User, + CaseUserActionsResponse, + CaseExternalServiceRequest, + ServiceConnectorCaseParams, + ServiceConnectorCaseResponse, + ActionTypeExecutorResult, } from '../../../../../../plugins/case/common/api'; import { KibanaServices } from '../../lib/kibana'; import { + ActionLicense, AllCases, BulkUpdateStatus, Case, @@ -23,16 +29,20 @@ import { Comment, FetchCasesProps, SortFieldCase, + CaseUserActions, } from './types'; import { CASES_URL } from './constants'; import { convertToCamelCase, convertAllCasesToCamel, + convertArrayToCamelCase, decodeCaseResponse, decodeCasesResponse, decodeCasesFindResponse, decodeCasesStatusResponse, decodeCommentResponse, + decodeCaseUserActionsResponse, + decodeServiceConnectorCaseResponse, } from './utils'; export const getCase = async (caseId: string, includeComments: boolean = true): Promise => { @@ -71,6 +81,20 @@ export const getReporters = async (signal: AbortSignal): Promise => { return response ?? []; }; +export const getCaseUserActions = async ( + caseId: string, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/user_actions`, + { + method: 'GET', + signal, + } + ); + return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[]; +}; + export const getCases = async ({ filterOptions = { search: '', @@ -161,3 +185,43 @@ export const deleteCases = async (caseIds: string[]): Promise => { }); return response === 'true' ? true : false; }; + +export const pushCase = async ( + caseId: string, + push: CaseExternalServiceRequest, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/_push`, + { + method: 'POST', + body: JSON.stringify(push), + signal, + } + ); + return convertToCamelCase(decodeCaseResponse(response)); +}; + +export const pushToService = async ( + connectorId: string, + casePushParams: ServiceConnectorCaseParams, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `/api/action/${connectorId}/_execute`, + { + method: 'POST', + body: JSON.stringify({ params: casePushParams }), + signal, + } + ); + return decodeServiceConnectorCaseResponse(response.data); +}; + +export const getActionLicense = async (signal: AbortSignal): Promise => { + const response = await KibanaServices.get().http.fetch(`/api/action/types`, { + method: 'GET', + signal, + }); + return response; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts index fc7aaa3643d77..d69c23fe02ec9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts @@ -26,6 +26,7 @@ export interface CaseConfigure { createdAt: string; createdBy: ElasticUser; connectorId: string; + connectorName: string; closureType: ClosureType; updatedAt: string; updatedBy: ElasticUser; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx index 22ac54093d1dc..a24f8303824c5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx @@ -13,6 +13,7 @@ import { ClosureType } from './types'; interface PersistCaseConfigure { connectorId: string; + connectorName: string; closureType: ClosureType; } @@ -24,12 +25,12 @@ export interface ReturnUseCaseConfigure { } interface UseCaseConfigure { - setConnectorId: (newConnectorId: string) => void; - setClosureType: (newClosureType: ClosureType) => void; + setConnector: (newConnectorId: string, newConnectorName?: string) => void; + setClosureType?: (newClosureType: ClosureType) => void; } export const useCaseConfigure = ({ - setConnectorId, + setConnector, setClosureType, }: UseCaseConfigure): ReturnUseCaseConfigure => { const [, dispatchToaster] = useStateToaster(); @@ -48,8 +49,10 @@ export const useCaseConfigure = ({ if (!didCancel) { setLoading(false); if (res != null) { - setConnectorId(res.connectorId); - setClosureType(res.closureType); + setConnector(res.connectorId, res.connectorName); + if (setClosureType != null) { + setClosureType(res.closureType); + } setVersion(res.version); } } @@ -74,7 +77,7 @@ export const useCaseConfigure = ({ }, []); const persistCaseConfigure = useCallback( - async ({ connectorId, closureType }: PersistCaseConfigure) => { + async ({ connectorId, connectorName, closureType }: PersistCaseConfigure) => { let didCancel = false; const abortCtrl = new AbortController(); const saveCaseConfiguration = async () => { @@ -83,7 +86,11 @@ export const useCaseConfigure = ({ const res = version.length === 0 ? await postCaseConfigure( - { connector_id: connectorId, closure_type: closureType }, + { + connector_id: connectorId, + connector_name: connectorName, + closure_type: closureType, + }, abortCtrl.signal ) : await patchCaseConfigure( @@ -92,8 +99,10 @@ export const useCaseConfigure = ({ ); if (!didCancel) { setPersistLoading(false); - setConnectorId(res.connectorId); - setClosureType(res.closureType); + setConnector(res.connectorId); + if (setClosureType) { + setClosureType(res.closureType); + } setVersion(res.version); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts index 0c8b896e2b426..601db373f041e 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts @@ -16,3 +16,10 @@ export const TAG_FETCH_FAILURE = i18n.translate( defaultMessage: 'Failed to fetch Tags', } ); + +export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = i18n.translate( + 'xpack.siem.containers.case.pushToExterService', + { + defaultMessage: 'Successfully sent to ServiceNow', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 44519031e91cb..bbbb13788d53a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,30 +4,53 @@ * you may not use this file except in compliance with the Elastic License. */ -import { User } from '../../../../../../plugins/case/common/api'; +import { User, UserActionField, UserAction } from '../../../../../../plugins/case/common/api'; export interface Comment { id: string; createdAt: string; createdBy: ElasticUser; comment: string; + pushedAt: string | null; + pushedBy: string | null; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; } +export interface CaseUserActions { + actionId: string; + actionField: UserActionField; + action: UserAction; + actionAt: string; + actionBy: ElasticUser; + caseId: string; + commentId: string | null; + newValue: string | null; + oldValue: string | null; +} +export interface CaseExternalService { + pushedAt: string; + pushedBy: string; + connectorId: string; + connectorName: string; + externalId: string; + externalTitle: string; + externalUrl: string; +} export interface Case { id: string; closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; - commentIds: string[]; createdAt: string; createdBy: ElasticUser; description: string; + externalService: CaseExternalService | null; status: string; tags: string[]; title: string; + totalComment: number; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; @@ -84,3 +107,10 @@ export interface BulkUpdateStatus { id: string; version: string; } +export interface ActionLicense { + id: string; + name: string; + enabled: boolean; + enabledInConfig: boolean; + enabledInLicense: boolean; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx new file mode 100644 index 0000000000000..12f92b2db039b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getActionLicense } from './api'; +import * as i18n from './translations'; +import { ActionLicense } from './types'; + +interface ActionLicenseState { + actionLicense: ActionLicense | null; + isLoading: boolean; + isError: boolean; +} + +const initialData: ActionLicenseState = { + actionLicense: null, + isLoading: true, + isError: false, +}; + +export const useGetActionLicense = (): ActionLicenseState => { + const [actionLicenseState, setActionLicensesState] = useState(initialData); + + const [, dispatchToaster] = useStateToaster(); + + const fetchActionLicense = useCallback(() => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setActionLicensesState({ + ...actionLicenseState, + isLoading: true, + }); + try { + const response = await getActionLicense(abortCtrl.signal); + if (!didCancel) { + setActionLicensesState({ + actionLicense: response.find(l => l.id === '.servicenow') ?? null, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setActionLicensesState({ + actionLicense: null, + isLoading: false, + isError: true, + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, [actionLicenseState]); + + useEffect(() => { + fetchActionLicense(); + }, []); + return { ...actionLicenseState }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index b70195e2c126f..02b41c9fc720f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -53,14 +53,15 @@ const initialData: Case = { closedBy: null, createdAt: '', comments: [], - commentIds: [], createdBy: { username: '', }, description: '', + externalService: null, status: '', tags: [], title: '', + totalComment: 0, updatedAt: null, updatedBy: null, version: '', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx new file mode 100644 index 0000000000000..4c278bc038134 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, uniqBy } from 'lodash/fp'; +import { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getCaseUserActions } from './api'; +import * as i18n from './translations'; +import { CaseUserActions, ElasticUser } from './types'; + +interface CaseUserActionsState { + caseUserActions: CaseUserActions[]; + firstIndexPushToService: number; + hasDataToPush: boolean; + participants: ElasticUser[]; + isLoading: boolean; + isError: boolean; + lastIndexPushToService: number; +} + +const initialData: CaseUserActionsState = { + caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, + isLoading: true, + isError: false, + participants: [], +}; + +interface UseGetCaseUserActions extends CaseUserActionsState { + fetchCaseUserActions: (caseId: string) => void; +} + +const getPushedInfo = ( + caseUserActions: CaseUserActions[] +): { firstIndexPushToService: number; lastIndexPushToService: number; hasDataToPush: boolean } => { + const firstIndexPushToService = caseUserActions.findIndex( + cua => cua.action === 'push-to-service' + ); + const lastIndexPushToService = caseUserActions + .map(cua => cua.action) + .lastIndexOf('push-to-service'); + + const hasDataToPush = + lastIndexPushToService === -1 || lastIndexPushToService < caseUserActions.length - 1; + return { + firstIndexPushToService, + lastIndexPushToService, + hasDataToPush, + }; +}; + +export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => { + const [caseUserActionsState, setCaseUserActionsState] = useState( + initialData + ); + + const [, dispatchToaster] = useStateToaster(); + + const fetchCaseUserActions = useCallback( + (thisCaseId: string) => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setCaseUserActionsState({ + ...caseUserActionsState, + isLoading: true, + }); + try { + const response = await getCaseUserActions(thisCaseId, abortCtrl.signal); + if (!didCancel) { + // Attention Future developer + // We are removing the first item because it will always be the creation of the case + // and we do not want it to simplify our life + const participants = !isEmpty(response) + ? uniqBy('actionBy.username', response).map(cau => cau.actionBy) + : []; + const caseUserActions = !isEmpty(response) ? response.slice(1) : []; + setCaseUserActionsState({ + caseUserActions, + ...getPushedInfo(caseUserActions), + isLoading: false, + isError: false, + participants, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setCaseUserActionsState({ + caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, + isLoading: false, + isError: true, + participants: [], + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, + [caseUserActionsState] + ); + + useEffect(() => { + if (!isEmpty(caseId)) { + fetchCaseUserActions(caseId); + } + }, [caseId]); + return { ...caseUserActionsState, fetchCaseUserActions }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx new file mode 100644 index 0000000000000..b6fb15f4fa083 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useReducer, useCallback } from 'react'; + +import { + ServiceConnectorCaseResponse, + ServiceConnectorCaseParams, +} from '../../../../../../plugins/case/common/api'; +import { errorToToaster, useStateToaster, displaySuccessToast } from '../../components/toasters'; + +import { getCase, pushToService, pushCase } from './api'; +import * as i18n from './translations'; +import { Case } from './types'; + +interface PushToServiceState { + serviceData: ServiceConnectorCaseResponse | null; + pushedCaseData: Case | null; + isLoading: boolean; + isError: boolean; +} +type Action = + | { type: 'FETCH_INIT' } + | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: ServiceConnectorCaseResponse | null } + | { type: 'FETCH_SUCCESS_PUSH_CASE'; payload: Case | null } + | { type: 'FETCH_FAILURE' }; + +const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS_PUSH_SERVICE': + return { + ...state, + isLoading: false, + isError: false, + serviceData: action.payload ?? null, + }; + case 'FETCH_SUCCESS_PUSH_CASE': + return { + ...state, + isLoading: false, + isError: false, + pushedCaseData: action.payload ?? null, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + return state; + } +}; + +interface PushToServiceRequest { + caseId: string; + connectorId: string; + connectorName: string; + updateCase: (newCase: Case) => void; +} + +interface UsePostPushToService extends PushToServiceState { + postPushToService: ({ caseId, connectorId, updateCase }: PushToServiceRequest) => void; +} + +export const usePostPushToService = (): UsePostPushToService => { + const [state, dispatch] = useReducer(dataFetchReducer, { + serviceData: null, + pushedCaseData: null, + isLoading: false, + isError: false, + }); + const [, dispatchToaster] = useStateToaster(); + + const postPushToService = useCallback( + async ({ caseId, connectorId, connectorName, updateCase }: PushToServiceRequest) => { + let cancel = false; + const abortCtrl = new AbortController(); + try { + dispatch({ type: 'FETCH_INIT' }); + const casePushData = await getCase(caseId); + const responseService = await pushToService( + connectorId, + formatServiceRequestData(casePushData), + abortCtrl.signal + ); + const responseCase = await pushCase( + caseId, + { + connector_id: connectorId, + connector_name: connectorName, + external_id: responseService.incidentId, + external_title: responseService.number, + external_url: responseService.url, + }, + abortCtrl.signal + ); + if (!cancel) { + dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService }); + dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase }); + updateCase(responseCase); + displaySuccessToast(i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE, dispatchToaster); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE' }); + } + } + return () => { + cancel = true; + abortCtrl.abort(); + }; + }, + [] + ); + + return { ...state, postPushToService }; +}; + +const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => { + const { + id: caseId, + createdAt, + createdBy, + comments, + description, + externalService, + title, + updatedAt, + updatedBy, + } = myCase; + + return { + caseId, + createdAt, + createdBy: { + fullName: createdBy.fullName ?? null, + username: createdBy?.username, + }, + comments: comments.map(c => ({ + commentId: c.id, + comment: c.comment, + createdAt: c.createdAt, + createdBy: { + fullName: c.createdBy.fullName ?? null, + username: c.createdBy.username, + }, + updatedAt: c.updatedAt, + updatedBy: + c.updatedBy != null + ? { + fullName: c.updatedBy.fullName ?? null, + username: c.updatedBy.username, + } + : null, + })), + description, + incidentId: externalService?.externalId ?? null, + title, + updatedAt, + updatedBy: + updatedBy != null + ? { + fullName: updatedBy.fullName ?? null, + username: updatedBy.username, + } + : null, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 987620469901b..f8af088f7e03b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -25,6 +25,7 @@ interface NewCaseState { export interface UpdateByKey { updateKey: UpdateKey; updateValue: CaseRequest[UpdateKey]; + fetchCaseUserActions?: (caseId: string) => void; } type Action = @@ -64,6 +65,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => interface UseUpdateCase extends NewCaseState { updateCaseProperty: (updates: UpdateByKey) => void; + updateCase: (newCase: Case) => void; } export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase => { const [state, dispatch] = useReducer(dataFetchReducer, { @@ -74,8 +76,12 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase }); const [, dispatchToaster] = useStateToaster(); + const updateCase = useCallback((newCase: Case) => { + dispatch({ type: 'FETCH_SUCCESS', payload: newCase }); + }, []); + const dispatchUpdateCaseProperty = useCallback( - async ({ updateKey, updateValue }: UpdateByKey) => { + async ({ fetchCaseUserActions, updateKey, updateValue }: UpdateByKey) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: updateKey }); @@ -85,6 +91,9 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase state.caseData.version ); if (!cancel) { + if (fetchCaseUserActions != null) { + fetchCaseUserActions(caseId); + } dispatch({ type: 'FETCH_SUCCESS', payload: response[0] }); } } catch (error) { @@ -104,5 +113,5 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase [state] ); - return { ...state, updateCaseProperty: dispatchUpdateCaseProperty }; + return { ...state, updateCase, updateCaseProperty: dispatchUpdateCaseProperty }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx index a40a1100ca735..c1b2bfde30126 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -70,8 +70,15 @@ const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpd } }; +interface UpdateComment { + caseId: string; + commentId: string; + commentUpdate: string; + fetchUserActions: () => void; +} + interface UseUpdateComment extends CommentUpdateState { - updateComment: (caseId: string, commentId: string, commentUpdate: string) => void; + updateComment: ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => void; addPostedComment: Dispatch; } @@ -84,7 +91,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { const [, dispatchToaster] = useStateToaster(); const dispatchUpdateComment = useCallback( - async (caseId: string, commentId: string, commentUpdate: string) => { + async ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: commentId }); @@ -98,6 +105,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { currentComment.version ); if (!cancel) { + fetchUserActions(); dispatch({ type: 'FETCH_SUCCESS', payload: { update: response, commentId } }); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 8f24d5a435240..ce23ac6c440b6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -23,6 +23,10 @@ import { CommentResponseRt, CasesConfigureResponse, CaseConfigureResponseRt, + CaseUserActionsResponse, + CaseUserActionsResponseRt, + ServiceConnectorCaseResponseRt, + ServiceConnectorCaseResponse, } from '../../../../../../plugins/case/common/api'; import { ToasterError } from '../../components/toasters'; import { AllCases, Case } from './types'; @@ -86,3 +90,15 @@ export const decodeCaseConfigureResponse = (respCase?: CasesConfigureResponse) = CaseConfigureResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity) ); + +export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsResponse) => + pipe( + CaseUserActionsResponseRt.decode(respUserActions), + fold(throwErrors(createToasterPlainError), identity) + ); + +export const decodeServiceConnectorCaseResponse = (respPushCase?: ServiceConnectorCaseResponse) => + pipe( + ServiceConnectorCaseResponseRt.decode(respPushCase), + fold(throwErrors(createToasterPlainError), identity) + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index 0b3b0daaf4bbc..836595c7c45d9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -30,72 +30,79 @@ const initialCommentValue: CommentRequest = { interface AddCommentProps { caseId: string; + onCommentSaving?: () => void; onCommentPosted: (commentResponse: Comment) => void; + showLoading?: boolean; } -export const AddComment = React.memo(({ caseId, onCommentPosted }) => { - const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); - const { form } = useForm({ - defaultValue: initialCommentValue, - options: { stripEmptyFields: false }, - schema, - }); - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - form, - 'comment' - ); +export const AddComment = React.memo( + ({ caseId, showLoading = true, onCommentPosted, onCommentSaving }) => { + const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); + const { form } = useForm({ + defaultValue: initialCommentValue, + options: { stripEmptyFields: false }, + schema, + }); + const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( + form, + 'comment' + ); - useEffect(() => { - if (commentData !== null) { - onCommentPosted(commentData); - form.reset(); - resetCommentData(); - } - }, [commentData]); + useEffect(() => { + if (commentData !== null) { + onCommentPosted(commentData); + form.reset(); + resetCommentData(); + } + }, [commentData]); - const onSubmit = useCallback(async () => { - const { isValid, data } = await form.submit(); - if (isValid) { - await postComment(data); - } - }, [form]); + const onSubmit = useCallback(async () => { + const { isValid, data } = await form.submit(); + if (isValid) { + if (onCommentSaving != null) { + onCommentSaving(); + } + await postComment(data); + } + }, [form]); - return ( - <> - {isLoading && } -
- - {i18n.ADD_COMMENT} - - ), - topRightContent: ( - - ), - }} - /> - - - ); -}); + return ( + <> + {isLoading && showLoading && } +
+ + {i18n.ADD_COMMENT} + + ), + topRightContent: ( + + ), + }} + /> + + + ); + } +); AddComment.displayName = 'AddComment'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 48fbb4e74c407..d4ec32dfd070b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -18,12 +18,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -34,12 +35,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -50,12 +52,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -66,14 +69,15 @@ export const useGetCasesMockState: UseGetCasesState = { id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: '2020-02-13T19:44:13.328Z', - updatedBy: { username: 'elastic' }, + totalComment: 0, + updatedAt: null, + updatedBy: null, version: 'WzQ3LDFd', }, { @@ -82,12 +86,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Uh oh', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index b9e1113c486ad..32a29483e9c75 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -35,6 +35,7 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); + export const getCasesColumns = ( actions: Array>, filterStatus: string @@ -108,11 +109,11 @@ export const getCasesColumns = ( }, { align: 'right', - field: 'commentIds', + field: 'totalComment', name: i18n.COMMENTS, sortable: true, - render: (comments: Case['commentIds']) => - renderStringField(`${comments.length}`, `case-table-column-commentCount`), + render: (totalComment: Case['totalComment']) => + renderStringField(`${totalComment}`, `case-table-column-commentCount`), }, filterStatus === 'open' ? { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 13869c79c45fd..bdcb87b483851 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -95,7 +95,9 @@ describe('AllCases', () => { .find(`a[data-test-subj="case-details-link"]`) .first() .prop('href') - ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].id}`); + ).toEqual( + `#/link-to/case/${useGetCasesMockState.data.cases[0].id}?timerange=(global:(linkTo:!(timeline),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)),timeline:(linkTo:!(global),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)))` + ); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index e7e1e624ccba2..87a2ea888831a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -35,7 +35,9 @@ import { UtilityBarText, } from '../../../../components/utility_bar'; import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; - +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; @@ -43,10 +45,6 @@ import { OpenClosedStats } from '../open_closed_stats'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; -import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; - -const CONFIGURE_CASES_URL = getConfigureCasesUrl(); -const CREATE_CASE_URL = getCreateCaseUrl(); const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -78,6 +76,7 @@ const getSortField = (field: string): SortFieldCase => { return SortFieldCase.createdAt; }; export const AllCases = React.memo(() => { + const urlSearch = useGetUrlSearch(navTabs.case); const { countClosedCases, countOpenCases, @@ -276,12 +275,12 @@ export const AllCases = React.memo(() => { /> - + {i18n.CONFIGURE_CASES_BUTTON} - + {i18n.CREATE_TITLE} @@ -342,7 +341,12 @@ export const AllCases = React.memo(() => { titleSize="xs" body={i18n.NO_CASES_BODY} actions={ - + {i18n.ADD_NEW_CASE} } diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx index 9dbd71ea3e34c..0420a71fea907 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React from 'react'; import styled, { css } from 'styled-components'; import { EuiBadge, @@ -39,7 +39,7 @@ interface CaseStatusProps { isSelected: boolean; status: string; title: string; - toggleStatusCase: (status: string) => void; + toggleStatusCase: (evt: unknown) => void; value: string | null; } const CaseStatusComp: React.FC = ({ @@ -55,51 +55,46 @@ const CaseStatusComp: React.FC = ({ title, toggleStatusCase, value, -}) => { - const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ - toggleStatusCase, - ]); - return ( - - - - - - {i18n.STATUS} - - - {status} - - - - - {title} - - - - - - - - - - - - +}) => ( + + + + - + {i18n.STATUS} + + + {status} + + + + + {title} + + + - - - ); -}; + + + + + + + + + + + + + +); export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index e11441eac3a9d..7aadea1a453a7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -13,7 +13,6 @@ export const caseProps: CaseProps = { closedAt: null, closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ { comment: 'Solve this fast!', @@ -24,6 +23,8 @@ export const caseProps: CaseProps = { username: 'smilovic', email: 'notmyrealemailfool@elastic.co', }, + pushedAt: null, + pushedBy: null, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { username: 'elastic', @@ -34,9 +35,11 @@ export const caseProps: CaseProps = { createdAt: '2020-02-13T19:44:23.627Z', createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach!!', + totalComment: 1, updatedAt: '2020-02-19T15:02:57.995Z', updatedBy: { username: 'elastic', @@ -44,6 +47,7 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; + export const caseClosedProps: CaseProps = { ...caseProps, initialData: { @@ -63,3 +67,21 @@ export const data: Case = { export const dataClosed: Case = { ...caseClosedProps.initialData, }; + +export const caseUserActions = [ + { + actionField: ['comment'], + action: 'create', + actionAt: '2020-03-20T17:10:09.814Z', + actionBy: { + fullName: 'Steph Milovic', + username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', + }, + newValue: 'Solve this fast!', + oldValue: null, + actionId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + caseId: '9b833a50-6acd-11ea-8fad-af86b1071bd9', + commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 3f4a83d1bff33..18cc33d8a6d4d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -11,11 +11,18 @@ import { mount } from 'enzyme'; import routeData from 'react-router'; /* eslint-enable @kbn/eslint/module_migration */ import { CaseComponent } from './'; -import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed, caseUserActions } from './__mock__'; import { TestProviders } from '../../../../mock'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { wait } from '../../../../lib/helpers'; +import { usePushToService } from './push_to_service'; jest.mock('../../../../containers/case/use_update_case'); +jest.mock('../../../../containers/case/use_get_case_user_actions'); +jest.mock('./push_to_service'); const useUpdateCaseMock = useUpdateCase as jest.Mock; +const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; +const usePushToServiceMock = usePushToService as jest.Mock; type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { @@ -47,6 +54,7 @@ const mockLocation = { describe('CaseView ', () => { const updateCaseProperty = jest.fn(); + const fetchCaseUserActions = jest.fn(); /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() const originalError = console.error; @@ -66,13 +74,31 @@ describe('CaseView ', () => { updateCaseProperty, }; + const defaultUseGetCaseUserActions = { + caseUserActions, + fetchCaseUserActions, + firstIndexPushToService: -1, + hasDataToPush: false, + isLoading: false, + isError: false, + lastIndexPushToService: -1, + participants: [data.createdBy], + }; + + const defaultUsePushToServiceMock = { + pushButton: <>{'Hello Button'}, + pushCallouts: null, + }; + beforeEach(() => { jest.resetAllMocks(); useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); + usePushToServiceMock.mockImplementation(() => defaultUsePushToServiceMock); }); - it('should render CaseComponent', () => { + it('should render CaseComponent', async () => { const wrapper = mount( @@ -80,6 +106,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find(`[data-test-subj="case-view-title"]`) @@ -119,7 +146,7 @@ describe('CaseView ', () => { ).toEqual(data.description); }); - it('should show closed indicators in header when case is closed', () => { + it('should show closed indicators in header when case is closed', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, caseData: dataClosed, @@ -131,6 +158,7 @@ describe('CaseView ', () => { ); + await wait(); expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); expect( wrapper @@ -146,7 +174,7 @@ describe('CaseView ', () => { ).toEqual(dataClosed.status); }); - it('should dispatch update state when button is toggled', () => { + it('should dispatch update state when button is toggled', async () => { const wrapper = mount( @@ -154,18 +182,19 @@ describe('CaseView ', () => { ); - + await wait(); wrapper .find('input[data-test-subj="toggle-case-status"]') .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ + fetchCaseUserActions, updateKey: 'status', updateValue: 'closed', }); }); - it('should render comments', () => { + it('should render comments', async () => { const wrapper = mount( @@ -173,6 +202,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 0ac3adeb860ff..742921cb9f69e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -4,10 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; - +import { + EuiButtonToggle, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, + EuiLoadingSpinner, + EuiHorizontalRule, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; + import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; import { getCaseUrl } from '../../../../components/link_to'; @@ -24,6 +31,8 @@ import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; import { SpyRoute } from '../../../../utils/route/spy_routes'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { usePushToService } from './push_to_service'; interface Props { caseId: string; @@ -37,6 +46,13 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` height: 100%; `; +const MyEuiHorizontalRule = styled(EuiHorizontalRule)` + margin-left: 48px; + &.euiHorizontalRule--full { + width: calc(100% - 48px); + } +`; + export interface CaseProps { caseId: string; initialData: Case; @@ -45,7 +61,20 @@ export interface CaseProps { export const CaseComponent = React.memo(({ caseId, initialData }) => { const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; - const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); + const [initLoadingData, setInitLoadingData] = useState(true); + const { + caseUserActions, + fetchCaseUserActions, + firstIndexPushToService, + hasDataToPush, + isLoading: isLoadingUserActions, + lastIndexPushToService, + participants, + } = useGetCaseUserActions(caseId); + const { caseData, isLoading, updateKey, updateCase, updateCaseProperty } = useUpdateCase( + caseId, + initialData + ); // Update Fields const onUpdateField = useCallback( @@ -55,6 +84,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const titleUpdate = getTypedPayload(updateValue); if (titleUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'title', updateValue: titleUpdate, }); @@ -64,6 +94,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const descriptionUpdate = getTypedPayload(updateValue); if (descriptionUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'description', updateValue: descriptionUpdate, }); @@ -72,6 +103,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => case 'tags': const tagsUpdate = getTypedPayload(updateValue); updateCaseProperty({ + fetchCaseUserActions, updateKey: 'tags', updateValue: tagsUpdate, }); @@ -80,6 +112,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const statusUpdate = getTypedPayload(updateValue); if (caseData.status !== updateValue) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'status', updateValue: statusUpdate, }); @@ -88,12 +121,29 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [caseData.status] + [fetchCaseUserActions, updateCaseProperty, caseData.status] + ); + const handleUpdateCase = useCallback( + (newCase: Case) => { + updateCase(newCase); + fetchCaseUserActions(newCase.id); + }, + [updateCase, fetchCaseUserActions] ); + + const { pushButton, pushCallouts } = usePushToService({ + caseId: caseData.id, + caseStatus: caseData.status, + isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0, + updateCase: handleUpdateCase, + }); + const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); - const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); - + const toggleStatusCase = useCallback( + e => onUpdateField('status', e.target.checked ? 'closed' : 'open'), + [onUpdateField] + ); const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); const caseStatusData = useMemo( @@ -111,7 +161,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } : { 'data-test-subj': 'case-view-closedAt', - value: caseData.closedAt, + value: caseData.closedAt ?? '', title: i18n.CASE_CLOSED, buttonLabel: i18n.REOPEN_CASE, status: caseData.status, @@ -126,8 +176,15 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => subject: i18n.EMAIL_SUBJECT(caseData.title), body: i18n.EMAIL_BODY(caseLink), }), - [caseData.title] + [caseLink, caseData.title] ); + + useEffect(() => { + if (initLoadingData && !isLoadingUserActions) { + setInitLoadingData(false); + } + }, [initLoadingData, isLoadingUserActions]); + return ( <> @@ -157,21 +214,52 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => + {pushCallouts != null && pushCallouts} - + {initLoadingData && } + {!initLoadingData && ( + <> + + + + + + + {hasDataToPush && {pushButton}} + + + )} + void; +} + +interface Connector { + connectorId: string; + connectorName: string; +} + +interface ReturnUsePushToService { + pushButton: JSX.Element; + pushCallouts: JSX.Element | null; +} + +export const usePushToService = ({ + caseId, + caseStatus, + updateCase, + isNew, +}: UsePushToService): ReturnUsePushToService => { + const urlSearch = useGetUrlSearch(navTabs.case); + const [connector, setConnector] = useState(null); + + const { isLoading, postPushToService } = usePostPushToService(); + + const handleSetConnector = useCallback((connectorId: string, connectorName?: string) => { + setConnector({ connectorId, connectorName: connectorName ?? '' }); + }, []); + + const { loading: loadingCaseConfigure } = useCaseConfigure({ + setConnector: handleSetConnector, + }); + + const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); + + const handlePushToService = useCallback(() => { + if (connector != null) { + postPushToService({ + caseId, + ...connector, + updateCase, + }); + } + }, [caseId, connector, postPushToService, updateCase]); + + const errorsMsg = useMemo(() => { + let errors: Array<{ title: string; description: JSX.Element }> = []; + if (actionLicense != null && !actionLicense.enabledInLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, + description: ( + + {i18n.LINK_CLOUD_DEPLOYMENT} + + ), + }} + /> + ), + }, + ]; + } + if (connector == null && !loadingCaseConfigure && !loadingLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE, + description: ( + + {i18n.LINK_CONNECTOR_CONFIGURE} + + ), + }} + /> + ), + }, + ]; + } + if (caseStatus === 'closed') { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE, + description: ( + + ), + }, + ]; + } + if (actionLicense != null && !actionLicense.enabledInConfig) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE, + description: ( + + {'coming soon...'} + + ), + }} + /> + ), + }, + ]; + } + return errors; + }, [actionLicense, caseStatus, connector, loadingCaseConfigure, loadingLicense, urlSearch]); + + const pushToServiceButton = useMemo( + () => ( + 0} + isLoading={isLoading} + > + {isNew ? i18n.PUSH_SERVICENOW : i18n.UPDATE_PUSH_SERVICENOW} + + ), + [isNew, handlePushToService, isLoading, loadingLicense, loadingCaseConfigure, errorsMsg] + ); + + const objToReturn = useMemo( + () => ({ + pushButton: + errorsMsg.length > 0 ? ( + {errorsMsg[0].description}

} + > + {pushToServiceButton} +
+ ) : ( + <>{pushToServiceButton} + ), + pushCallouts: errorsMsg.length > 0 ? : null, + }), + [errorsMsg, pushToServiceButton] + ); + return objToReturn; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index e5fa3bff51f85..beba80ccd934c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -18,17 +18,40 @@ export const SHOWING_CASES = (actionDate: string, actionName: string, userName: defaultMessage: '{userName} {actionName} on {actionDate}', }); -export const ADDED_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.actionLabel.addDescription', +export const ADDED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.addedField', { + defaultMessage: 'added', +}); + +export const CHANGED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.changededField', { + defaultMessage: 'changed', +}); + +export const EDITED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.editedField', { + defaultMessage: 'edited', +}); + +export const REMOVED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.removedField', { + defaultMessage: 'removed', +}); + +export const PUSHED_NEW_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.pushedNewIncident', { - defaultMessage: 'added description', + defaultMessage: 'pushed as new incident', + } +); + +export const UPDATE_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.updateIncident', + { + defaultMessage: 'updated incident', } ); -export const EDITED_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.actionLabel.editDescription', +export const ADDED_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.addDescription', { - defaultMessage: 'edited description', + defaultMessage: 'added description', } ); @@ -52,6 +75,14 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { defaultMessage: 'Status', }); +export const CASE = i18n.translate('xpack.siem.case.caseView.case', { + defaultMessage: 'case', +}); + +export const COMMENT = i18n.translate('xpack.siem.case.caseView.comment', { + defaultMessage: 'comment', +}); + export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); @@ -71,3 +102,56 @@ export const EMAIL_BODY = (caseUrl: string) => values: { caseUrl }, defaultMessage: 'Case reference: {caseUrl}', }); + +export const PUSH_SERVICENOW = i18n.translate('xpack.siem.case.caseView.pushAsServicenowIncident', { + defaultMessage: 'Push as ServiceNow incident', +}); + +export const UPDATE_PUSH_SERVICENOW = i18n.translate( + 'xpack.siem.case.caseView.updatePushAsServicenowIncident', + { + defaultMessage: 'Update ServiceNow incident', + } +); + +export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle', + { + defaultMessage: 'Configure external connector', + } +); + +export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle', + { + defaultMessage: 'Reopen the case', + } +); + +export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByConfigTitle', + { + defaultMessage: 'Enable ServiceNow in Kibana configuration file', + } +); + +export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle', + { + defaultMessage: 'Upgrade to Elastic Platinum', + } +); + +export const LINK_CLOUD_DEPLOYMENT = i18n.translate( + 'xpack.siem.case.caseView.cloudDeploymentLink', + { + defaultMessage: 'cloud deployment', + } +); + +export const LINK_CONNECTOR_CONFIGURE = i18n.translate( + 'xpack.siem.case.caseView.connectorConfigureLink', + { + defaultMessage: 'connector', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx index c8ef6e32595d0..fb4d91492c1d4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -113,7 +113,7 @@ const ConfigureCasesComponent: React.FC = () => { }, []); const { loading: loadingCaseConfigure, persistLoading, persistCaseConfigure } = useCaseConfigure({ - setConnectorId, + setConnector: setConnectorId, setClosureType, }); const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); @@ -128,9 +128,13 @@ const ConfigureCasesComponent: React.FC = () => { // TO DO give a warning/error to user when field are not mapped so they have chance to do it () => { setActionBarVisible(false); - persistCaseConfigure({ connectorId, closureType }); + persistCaseConfigure({ + connectorId, + connectorName: connectors.find(c => c.id === connectorId)?.name ?? '', + closureType, + }); }, - [connectorId, closureType, mapping] + [connectorId, connectors, closureType, mapping] ); const onChangeConnector = useCallback((newConnectorId: string) => { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx new file mode 100644 index 0000000000000..15b50e4b4cd8d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton, EuiDescriptionList, EuiSpacer } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +interface ErrorsPushServiceCallOut { + errors: Array<{ title: string; description: JSX.Element }>; +} + +const ErrorsPushServiceCallOutComponent = ({ errors }: ErrorsPushServiceCallOut) => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + <> + + + + {i18n.DISMISS_CALLOUT} + + + + + ) : null; +}; + +export const ErrorsPushServiceCallOut = memo(ErrorsPushServiceCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts new file mode 100644 index 0000000000000..57712e720f6d0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.case.errorsPushServiceCallOutTitle', + { + defaultMessage: 'To send cases to external systems, you need to:', + } +); + +export const DISMISS_CALLOUT = i18n.translate( + 'xpack.siem.case.dismissErrorsPushServiceCallOutTitle', + { + defaultMessage: 'Dismiss', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx new file mode 100644 index 0000000000000..008f4d7048f56 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui'; +import React from 'react'; + +import { CaseFullExternalService } from '../../../../../../../../plugins/case/common/api'; +import { CaseUserActions } from '../../../../containers/case/types'; +import * as i18n from '../case_view/translations'; + +interface LabelTitle { + action: CaseUserActions; + field: string; + firstIndexPushToService: number; + index: number; +} + +export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: LabelTitle) => { + if (field === 'tags') { + return getTagsLabelTitle(action); + } else if (field === 'title' && action.action === 'update') { + return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ + action.newValue + }"`; + } else if (field === 'description' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; + } else if (field === 'status' && action.action === 'update') { + return `${ + action.newValue === 'open' ? i18n.REOPENED_CASE.toLowerCase() : i18n.CLOSED_CASE.toLowerCase() + } ${i18n.CASE}`; + } else if (field === 'comment' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; + } else if (field === 'pushed' && action.action === 'push-to-service' && action.newValue != null) { + return getPushedServiceLabelTitle(action, firstIndexPushToService, index); + } + return ''; +}; + +const getTagsLabelTitle = (action: CaseUserActions) => ( + + + {action.action === 'add' && i18n.ADDED_FIELD} + {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + {action.newValue != null && + action.newValue.split(',').map(tag => ( + + {tag} + + ))} + +); + +const getPushedServiceLabelTitle = ( + action: CaseUserActions, + firstIndexPushToService: number, + index: number +) => { + const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; + return ( + + + {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} + + + + {pushedVal?.connector_name} {pushedVal?.external_title} + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 6a3d319561353..8b77186f76f77 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -4,27 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; + import * as i18n from '../case_view/translations'; -import { Case } from '../../../../containers/case/types'; +import { Case, CaseUserActions, Comment } from '../../../../containers/case/types'; import { useUpdateComment } from '../../../../containers/case/use_update_comment'; +import { useCurrentUser } from '../../../../lib/kibana'; +import { AddComment } from '../add_comment'; +import { getLabelTitle } from './helpers'; import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; -import { AddComment } from '../add_comment'; -import { useCurrentUser } from '../../../../lib/kibana'; export interface UserActionTreeProps { data: Case; + caseUserActions: CaseUserActions[]; + fetchUserActions: () => void; + firstIndexPushToService: number; isLoadingDescription: boolean; + isLoadingUserActions: boolean; + lastIndexPushToService: number; onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; } -const DescriptionId = 'description'; -const NewId = 'newComment'; +const MyEuiFlexGroup = styled(EuiFlexGroup)` + margin-bottom: 8px; +`; + +const DESCRIPTION_ID = 'description'; +const NEW_ID = 'newComment'; export const UserActionTree = React.memo( - ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { + ({ + data: caseData, + caseUserActions, + fetchUserActions, + firstIndexPushToService, + isLoadingDescription, + isLoadingUserActions, + lastIndexPushToService, + onUpdateField, + }: UserActionTreeProps) => { + const { commentId } = useParams(); + const handlerTimeoutId = useRef(0); + const [initLoading, setInitLoading] = useState(true); + const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment( caseData.comments ); @@ -45,20 +72,54 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( (id: string, content: string) => { handleManageMarkdownEditId(id); - updateComment(caseData.id, id, content); + updateComment({ + caseId: caseData.id, + commentId: id, + commentUpdate: content, + fetchUserActions, + }); }, [handleManageMarkdownEditId, updateComment] ); + const handleOutlineComment = useCallback( + (id: string) => { + const moveToTarget = document.getElementById(`${id}-permLink`); + if (moveToTarget != null) { + const yOffset = -60; + const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset; + window.scrollTo({ + top: y, + behavior: 'smooth', + }); + } + window.clearTimeout(handlerTimeoutId.current); + setSelectedOutlineCommentId(id); + handlerTimeoutId.current = window.setTimeout(() => { + setSelectedOutlineCommentId(''); + window.clearTimeout(handlerTimeoutId.current); + }, 2400); + }, + [handlerTimeoutId.current] + ); + + const handleUpdate = useCallback( + (comment: Comment) => { + addPostedComment(comment); + fetchUserActions(); + }, + [addPostedComment, fetchUserActions] + ); + const MarkdownDescription = useMemo( () => ( { - handleManageMarkdownEditId(DescriptionId); - onUpdateField(DescriptionId, content); + handleManageMarkdownEditId(DESCRIPTION_ID); + onUpdateField(DESCRIPTION_ID, content); }} onChangeEditable={handleManageMarkdownEditId} /> @@ -67,55 +128,123 @@ export const UserActionTree = React.memo( ); const MarkdownNewComment = useMemo( - () => , - [caseData.id] + () => ( + + ), + [caseData.id, handleUpdate] ); + useEffect(() => { + if (initLoading && !isLoadingUserActions && isLoadingIds.length === 0) { + setInitLoading(false); + if (commentId != null) { + handleOutlineComment(commentId); + } + } + }, [commentId, initLoading, isLoadingUserActions, isLoadingIds]); + return ( <> {i18n.ADDED_DESCRIPTION}} fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} - onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)} + onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} userName={caseData.createdBy.username} /> - {comments.map(comment => ( - + + {caseUserActions.map((action, index) => { + if (action.commentId != null && action.action === 'create') { + const comment = comments.find(c => c.id === action.commentId); + if (comment != null) { + return ( + {i18n.ADDED_COMMENT}} + fullName={comment.createdBy.fullName ?? comment.createdBy.username} + markdown={ + + } + onEdit={handleManageMarkdownEditId.bind(null, comment.id)} + outlineComment={handleOutlineComment} + userName={comment.createdBy.username} + updatedAt={comment.updatedAt} + /> + ); } - onEdit={handleManageMarkdownEditId.bind(null, comment.id)} - userName={comment.createdBy.username} - /> - ))} + } + if (action.actionField.length === 1) { + const myField = action.actionField[0]; + const labelTitle: string | JSX.Element = getLabelTitle({ + action, + field: myField, + firstIndexPushToService, + index, + }); + + return ( + {labelTitle}} + linkId={ + action.action === 'update' && action.commentId != null ? action.commentId : null + } + fullName={action.actionBy.fullName ?? action.actionBy.username} + outlineComment={handleOutlineComment} + showTopFooter={ + action.action === 'push-to-service' && index === lastIndexPushToService + } + showBottomFooter={ + action.action === 'push-to-service' && + index === lastIndexPushToService && + index < caseUserActions.length - 1 + } + userName={action.actionBy.username} + /> + ); + } + return null; + })} + {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( + + + + + + )} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts new file mode 100644 index 0000000000000..0ca6bcff513fc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../case_view/translations'; + +export const ALREADY_PUSHED_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.alreadyPushedToService', + { + defaultMessage: 'Already pushed to Service Now incident', + } +); + +export const REQUIRED_UPDATE_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.requiredUpdateToService', + { + defaultMessage: 'Requires update to ServiceNow incident', + } +); + +export const COPY_LINK_COMMENT = i18n.translate('xpack.siem.case.caseView.copyCommentLinkAria', { + defaultMessage: 'click to copy comment link', +}); + +export const MOVE_TO_ORIGINAL_COMMENT = i18n.translate( + 'xpack.siem.case.caseView.moveToCommentAria', + { + defaultMessage: 'click to highlight the reference comment', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index ca73f200f1793..10a7c56e2eb2d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -4,12 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiHorizontalRule, + EuiText, +} from '@elastic/eui'; import React from 'react'; - import styled, { css } from 'styled-components'; + import { UserActionAvatar } from './user_action_avatar'; import { UserActionTitle } from './user_action_title'; +import * as i18n from './translations'; interface UserActionItemProps { createdAt: string; @@ -17,14 +25,20 @@ interface UserActionItemProps { isEditable: boolean; isLoading: boolean; labelEditAction?: string; - labelTitle?: string; + labelTitle?: JSX.Element; + linkId?: string | null; fullName: string; - markdown: React.ReactNode; - onEdit: (id: string) => void; + markdown?: React.ReactNode; + onEdit?: (id: string) => void; userName: string; + updatedAt?: string | null; + outlineComment?: (id: string) => void; + showBottomFooter?: boolean; + showTopFooter?: boolean; + idToOutline?: string | null; } -const UserActionItemContainer = styled(EuiFlexGroup)` +export const UserActionItemContainer = styled(EuiFlexGroup)` ${({ theme }) => css` & { background-image: linear-gradient( @@ -66,42 +80,102 @@ const UserActionItemContainer = styled(EuiFlexGroup)` `} `; +const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` + ${({ theme, showoutline }) => + showoutline === 'true' + ? ` + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + ` + : ''} +`; + +const PushedContainer = styled(EuiFlexItem)` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSizeS}; + margin-bottom: ${theme.eui.euiSizeXL}; + hr { + margin: 5px; + height: ${theme.eui.euiBorderWidthThick}; + } + `} +`; + +const PushedInfoContainer = styled.div` + margin-left: 48px; +`; + export const UserActionItem = ({ createdAt, id, + idToOutline, isEditable, isLoading, labelEditAction, labelTitle, + linkId, fullName, markdown, onEdit, + outlineComment, + showBottomFooter, + showTopFooter, userName, + updatedAt, }: UserActionItemProps) => ( - - - {fullName.length > 0 || userName.length > 0 ? ( - - ) : ( - - )} - - - {isEditable && markdown} - {!isEditable && ( - - - {markdown} - - )} + + + + + {fullName.length > 0 || userName.length > 0 ? ( + + ) : ( + + )} + + + {isEditable && markdown} + {!isEditable && ( + + } + linkId={linkId} + userName={userName} + updatedAt={updatedAt} + onEdit={onEdit} + outlineComment={outlineComment} + /> + {markdown} + + )} + + + {showTopFooter && ( + + + + {i18n.ALREADY_PUSHED_TO_SERVICE} + + + + {showBottomFooter && ( + + + {i18n.REQUIRED_UPDATE_TO_SERVICE} + + + )} + + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 0ed081e8852f0..6ca81667d9712 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import copy from 'copy-to-clipboard'; +import { isEmpty } from 'lodash/fp'; +import React, { useMemo, useCallback } from 'react'; import styled from 'styled-components'; +import { useParams } from 'react-router-dom'; -import { - FormattedRelativePreferenceDate, - FormattedRelativePreferenceLabel, -} from '../../../../components/formatted_date'; -import * as i18n from '../case_view/translations'; +import { LocalizedDateTooltip } from '../../../../components/localized_date_tooltip'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import { PropertyActions } from '../property_actions'; +import { SiemPageName } from '../../../home/types'; +import * as i18n from './translations'; const MySpinner = styled(EuiLoadingSpinner)` .euiLoadingSpinner { @@ -25,10 +29,13 @@ interface UserActionTitleProps { createdAt: string; id: string; isLoading: boolean; - labelEditAction: string; - labelTitle: string; + labelEditAction?: string; + labelTitle: JSX.Element; + linkId?: string | null; + updatedAt?: string | null; userName: string; - onEdit: (id: string) => void; + onEdit?: (id: string) => void; + outlineComment?: (id: string) => void; } export const UserActionTitle = ({ @@ -37,32 +44,107 @@ export const UserActionTitle = ({ isLoading, labelEditAction, labelTitle, + linkId, userName, + updatedAt, onEdit, + outlineComment, }: UserActionTitleProps) => { + const { detailName: caseId } = useParams(); + const urlSearch = useGetUrlSearch(navTabs.case); const propertyActions = useMemo(() => { - return [ + if (labelEditAction != null && onEdit != null) { + return [ + { + iconType: 'pencil', + label: labelEditAction, + onClick: () => onEdit(id), + }, + ]; + } + return []; + }, [id, labelEditAction, onEdit]); + + const handleAnchorLink = useCallback(() => { + copy( + `${window.location.origin}${window.location.pathname}#${SiemPageName.case}/${caseId}/${id}${urlSearch}`, { - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ]; - }, [id, onEdit]); + debug: true, + } + ); + }, [caseId, id, urlSearch]); + + const handleMoveToLink = useCallback(() => { + if (outlineComment != null && linkId != null) { + outlineComment(linkId); + } + }, [linkId, outlineComment]); + return ( - + -

- {userName} - {` ${labelTitle} `} - - -

+ + + {userName} + + {labelTitle} + + + + + + {updatedAt != null && ( + + + {'('} + {i18n.EDITED_FIELD}{' '} + + + + {')'} + + + )} +
- {isLoading && } - {!isLoading && } + + {!isEmpty(linkId) && ( + + + + )} + + + + {propertyActions.length > 0 && ( + + {isLoading && } + {!isLoading && } + + )} +
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx index 1bde9de1535b5..124cefa726a8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx @@ -15,6 +15,7 @@ import { ConfigureCasesPage } from './configure_cases'; const casesPagePath = `/:pageName(${SiemPageName.case})`; const caseDetailsPagePath = `${casesPagePath}/:detailName`; +const caseDetailsPagePathWithCommentId = `${casesPagePath}/:detailName/:commentId`; const createCasePagePath = `${casesPagePath}/create`; const configureCasesPagePath = `${casesPagePath}/configure`; @@ -29,6 +30,9 @@ const CaseContainerComponent: React.FC = () => ( + + + diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 341a34240fe49..8f9d2087699f8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -33,17 +33,15 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { defaultMessage: 'Closed on', }); -export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { - defaultMessage: 'Reopen case', -}); -export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { - defaultMessage: 'Close case', -}); -export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { +export const REPORTER = i18n.translate('xpack.siem.case.caseView.reporterLabel', { defaultMessage: 'Reporter', }); +export const PARTICIPANTS = i18n.translate('xpack.siem.case.caseView.particpantsLabel', { + defaultMessage: 'Participants', +}); + export const CREATE_BC_TITLE = i18n.translate('xpack.siem.case.caseView.breadcrumb', { defaultMessage: 'Create', }); @@ -90,6 +88,30 @@ export const CREATE_CASE = i18n.translate('xpack.siem.case.caseView.createCase', defaultMessage: 'Create case', }); +export const CLOSED_CASE = i18n.translate('xpack.siem.case.caseView.closedCase', { + defaultMessage: 'Closed case', +}); + +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseView.closeCase', { + defaultMessage: 'Close case', +}); + +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseView.reopenCase', { + defaultMessage: 'Reopen case', +}); + +export const REOPENED_CASE = i18n.translate('xpack.siem.case.caseView.reopenedCase', { + defaultMessage: 'Reopened case', +}); + +export const CASE_NAME = i18n.translate('xpack.siem.case.caseView.caseName', { + defaultMessage: 'Case name', +}); + +export const TO = i18n.translate('xpack.siem.case.caseView.to', { + defaultMessage: 'to', +}); + export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { defaultMessage: 'Tags', }); @@ -130,7 +152,7 @@ export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate( ); export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.siem.case.configureCasesButton', { - defaultMessage: 'Edit third-party connection', + defaultMessage: 'Edit external connection', }); export const ADD_COMMENT = i18n.translate('xpack.siem.case.caseView.comment.addComment', { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index ccb3b71a476ec..3f2964b8cdd6d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -21,7 +21,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: i18n.CREATE_BC_TITLE, - href: getCreateCaseUrl(), + href: getCreateCaseUrl(''), }, ]; } else if (params.detailName != null) { @@ -29,7 +29,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: params.state?.caseTitle ?? '', - href: getCaseDetailsUrl(params.detailName), + href: getCaseDetailsUrl(params.detailName, ''), }, ]; } diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 6f58e2702ec5b..ee244dd205113 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -11,6 +11,8 @@ import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; +export { ActionTypeExecutorResult } from '../../../../actions/server/types'; + const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); const CaseBasicRt = rt.type({ @@ -20,14 +22,33 @@ const CaseBasicRt = rt.type({ title: rt.string, }); +const CaseExternalServiceBasicRt = rt.type({ + connector_id: rt.string, + connector_name: rt.string, + external_id: rt.string, + external_title: rt.string, + external_url: rt.string, +}); + +const CaseFullExternalServiceRt = rt.union([ + rt.intersection([ + CaseExternalServiceBasicRt, + rt.type({ + pushed_at: rt.string, + pushed_by: UserRT, + }), + ]), + rt.null, +]); + export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ - comment_ids: rt.array(rt.string), closed_at: rt.union([rt.string, rt.null]), closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, + external_service: CaseFullExternalServiceRt, updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), @@ -35,6 +56,8 @@ export const CaseAttributesRt = rt.intersection([ export const CaseRequestRt = CaseBasicRt; +export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; + export const CasesFindRequestRt = rt.partial({ tags: rt.union([rt.array(rt.string), rt.string]), status: StatusRt, @@ -53,6 +76,7 @@ export const CaseResponseRt = rt.intersection([ CaseAttributesRt, rt.type({ id: rt.string, + totalComment: rt.number, version: rt.string, }), rt.partial({ @@ -78,6 +102,60 @@ export const CasePatchRequestRt = rt.intersection([ export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); +/* + * This type are related to this file below + * x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts + * why because this schema is not share in a common folder + * so we redefine then so we can use/validate types + */ + +const ServiceConnectorUserParams = rt.type({ + fullName: rt.union([rt.string, rt.null]), + username: rt.string, +}); + +export const ServiceConnectorCommentParamsRt = rt.type({ + commentId: rt.string, + comment: rt.string, + createdAt: rt.string, + createdBy: ServiceConnectorUserParams, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), +}); + +export const ServiceConnectorCaseParamsRt = rt.intersection([ + rt.type({ + caseId: rt.string, + createdAt: rt.string, + createdBy: ServiceConnectorUserParams, + incidentId: rt.union([rt.string, rt.null]), + title: rt.string, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), + }), + rt.partial({ + description: rt.string, + comments: rt.array(ServiceConnectorCommentParamsRt), + }), +]); + +export const ServiceConnectorCaseResponseRt = rt.intersection([ + rt.type({ + number: rt.string, + incidentId: rt.string, + pushedDate: rt.string, + url: rt.string, + }), + rt.partial({ + comments: rt.array( + rt.type({ + commentId: rt.string, + pushedDate: rt.string, + }) + ), + }), +]); + export type CaseAttributes = rt.TypeOf; export type CaseRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; @@ -85,3 +163,8 @@ export type CasesResponse = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; +export type CaseExternalServiceRequest = rt.TypeOf; +export type ServiceConnectorCaseParams = rt.TypeOf; +export type ServiceConnectorCaseResponse = rt.TypeOf; +export type CaseFullExternalService = rt.TypeOf; +export type ServiceConnectorCommentParams = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index cebfa00425728..4549b1c31a7cf 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -17,6 +17,8 @@ export const CommentAttributesRt = rt.intersection([ rt.type({ created_at: rt.string, created_by: UserRT, + pushed_at: rt.union([rt.string, rt.null]), + pushed_by: rt.union([UserRT, rt.null]), updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index e0489ed7270fa..9b210c2aa05ad 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -73,6 +73,7 @@ const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-b const CasesConfigureBasicRt = rt.type({ connector_id: rt.string, + connector_name: rt.string, closure_type: ClosureTypeRT, }); diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index 5fbee98bc57ad..ffcd4d25eecf5 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -8,3 +8,4 @@ export * from './case'; export * from './configure'; export * from './comment'; export * from './status'; +export * from './user_actions'; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts new file mode 100644 index 0000000000000..2b70a698a5152 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { UserRT } from '../user'; + +/* To the next developer, if you add/removed fields here + * make sure to check this file (x-pack/plugins/case/server/services/user_actions/helpers.ts) too + */ +const UserActionFieldRt = rt.array( + rt.union([ + rt.literal('comment'), + rt.literal('description'), + rt.literal('pushed'), + rt.literal('tags'), + rt.literal('title'), + rt.literal('status'), + ]) +); +const UserActionRt = rt.union([ + rt.literal('add'), + rt.literal('create'), + rt.literal('delete'), + rt.literal('update'), + rt.literal('push-to-service'), +]); + +// TO DO change state to status +const CaseUserActionBasicRT = rt.type({ + action_field: UserActionFieldRt, + action: UserActionRt, + action_at: rt.string, + action_by: UserRT, + new_value: rt.union([rt.string, rt.null]), + old_value: rt.union([rt.string, rt.null]), +}); + +const CaseUserActionResponseRT = rt.intersection([ + CaseUserActionBasicRT, + rt.type({ + action_id: rt.string, + case_id: rt.string, + comment_id: rt.union([rt.string, rt.null]), + }), +]); + +export const CaseUserActionAttributesRt = CaseUserActionBasicRT; + +export const CaseUserActionsResponseRt = rt.array(CaseUserActionResponseRT); + +export type CaseUserActionAttributes = rt.TypeOf; +export type CaseUserActionsResponse = rt.TypeOf; + +export type UserAction = rt.TypeOf; +export type UserActionField = rt.TypeOf; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 1d6495c2d81f3..a6a459373b0ed 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -16,8 +16,9 @@ import { caseSavedObjectType, caseConfigureSavedObjectType, caseCommentSavedObjectType, + caseUserActionSavedObjectType, } from './saved_object_types'; -import { CaseConfigureService, CaseService } from './services'; +import { CaseConfigureService, CaseService, CaseUserActionService } from './services'; function createConfig$(context: PluginInitializerContext) { return context.config.create().pipe(map(config => config)); @@ -46,9 +47,11 @@ export class CasePlugin { core.savedObjects.registerType(caseSavedObjectType); core.savedObjects.registerType(caseCommentSavedObjectType); core.savedObjects.registerType(caseConfigureSavedObjectType); + core.savedObjects.registerType(caseUserActionSavedObjectType); const caseServicePlugin = new CaseService(this.log); const caseConfigureServicePlugin = new CaseConfigureService(this.log); + const userActionServicePlugin = new CaseUserActionService(this.log); this.log.debug( `Setting up Case Workflow with core contract [${Object.keys( @@ -60,11 +63,13 @@ export class CasePlugin { authentication: plugins.security.authc, }); const caseConfigureService = await caseConfigureServicePlugin.setup(); + const userActionService = await userActionServicePlugin.setup(); const router = core.http.createRouter(); initCaseApi({ caseConfigureService, caseService, + userActionService, router, }); } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index bc41ddbeff1f9..eff91fff32c02 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -32,6 +32,10 @@ export const createRoute = async ( caseConfigureService, caseService, router, + userActionService: { + postUserActions: jest.fn(), + getUserActions: jest.fn(), + }, }); return router[method].mock.calls[0][1]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 5aa8b93f17b08..03da50f886fd5 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -14,7 +14,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -22,6 +21,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, title: 'Super Bad Security Issue', status: 'open', tags: ['defacement'], @@ -42,7 +42,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -50,6 +49,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', + external_service: null, title: 'Damaging Data Destruction Detected', status: 'open', tags: ['Data Destruction'], @@ -70,7 +70,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -78,6 +77,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', + external_service: null, title: 'Another bad one', status: 'open', tags: ['LOLBins'], @@ -102,7 +102,6 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, - comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -110,8 +109,9 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', - title: 'Another bad one', + external_service: null, status: 'closed', + title: 'Another bad one', tags: ['LOLBins'], updated_at: '2019-11-25T22:32:17.947Z', updated_by: { @@ -147,6 +147,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T21:55:00.177Z', updated_by: { full_name: 'elastic', @@ -175,6 +177,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T21:55:14.633Z', updated_by: { full_name: 'elastic', @@ -204,6 +208,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index 00d06bfdd2677..941ac90f2e90e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -5,10 +5,12 @@ */ import { schema } from '@kbn/config-schema'; + +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { +export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases/{case_id}/comments', @@ -21,9 +23,11 @@ export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); const comments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); await Promise.all( @@ -35,15 +39,18 @@ export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { ) ); - const updateCase = { - comment_ids: [], - }; - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: comments.saved_objects.map(comment => + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + commentId: comment.id, + fields: ['comment'], + }) + ), }); return response.ok({ body: 'true' }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 85c4701f82e1d..44e57fc809e04 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -6,10 +6,13 @@ import Boom from 'boom'; import { schema } from '@kbn/config-schema'; + +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -export function initDeleteCommentApi({ caseService, router }: RouteDeps) { +export function initDeleteCommentApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases/{case_id}/comments/{comment_id}', @@ -23,14 +26,22 @@ export function initDeleteCommentApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); + + const myComment = await caseService.getComment({ + client, + commentId: request.params.comment_id, }); - if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { + if (myComment == null) { + throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); + } + + const caseRef = myComment.references.find(c => c.type === CASE_SAVED_OBJECT); + if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + `This comment ${request.params.comment_id} does not exist in ${request.params.case_id}).` ); } @@ -39,17 +50,18 @@ export function initDeleteCommentApi({ caseService, router }: RouteDeps) { commentId: request.params.comment_id, }); - const updateCase = { - comment_ids: myCase.attributes.comment_ids.filter( - cId => cId !== request.params.comment_id - ), - }; - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + commentId: request.params.comment_id, + fields: ['comment'], + }), + ], }); return response.ok({ body: 'true' }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index dcf70d0d9819c..92da64cebee74 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -32,6 +32,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( SavedObjectFindOptionsRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) @@ -39,7 +40,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { const args = query ? { - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, options: { ...query, @@ -47,7 +48,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { }, } : { - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 65f2de7125236..1500039eb2cc2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -22,8 +22,9 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const comments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts index 06619abae8487..24f44a5f5129b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts @@ -5,7 +5,6 @@ */ import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; import { CommentResponseRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; @@ -25,16 +24,6 @@ export function initGetCommentApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const myCase = await caseService.getCase({ - client, - caseId: request.params.case_id, - }); - - if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { - throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` - ); - } const comment = await caseService.getComment({ client, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index c14a94e84e51c..c67ad1bdaea71 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -11,11 +11,12 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { CommentPatchRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; - +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, flattenCommentSavedObject } from '../../utils'; -export function initPatchCommentApi({ caseService, router }: RouteDeps) { +export function initPatchCommentApi({ caseService, router, userActionService }: RouteDeps) { router.patch( { path: '/api/cases/{case_id}/comments', @@ -28,46 +29,63 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CommentPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, + const myComment = await caseService.getComment({ + client, + commentId: query.id, }); - if (!myCase.attributes.comment_ids.includes(query.id)) { + if (myComment == null) { + throw Boom.notFound(`This comment ${query.id} does not exist anymore.`); + } + + const caseRef = myComment.references.find(c => c.type === CASE_SAVED_OBJECT); + if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { throw Boom.notFound( - `This comment ${query.id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + `This comment ${query.id} does not exist in ${request.params.case_id}).` ); } - const myComment = await caseService.getComment({ - client: context.core.savedObjects.client, - commentId: query.id, - }); - if (query.version !== myComment.version) { throw Boom.conflict( 'This case has been updated. Please refresh before saving additional updates.' ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); + const updatedDate = new Date().toISOString(); const updatedComment = await caseService.patchComment({ - client: context.core.savedObjects.client, + client, commentId: query.id, updatedAttributes: { comment: query.comment, - updated_at: new Date().toISOString(), + updated_at: updatedDate, updated_by: { email, full_name, username }, }, version: query.version, }); + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'update', + actionAt: updatedDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + commentId: updatedComment.id, + fields: ['comment'], + newValue: query.comment, + oldValue: myComment.attributes.comment, + }), + ], + }); + return response.ok({ body: CommentResponseRt.encode( flattenCommentSavedObject({ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 9e82a8ffaaec7..2410505872a3a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -12,6 +12,7 @@ import { identity } from 'fp-ts/lib/function'; import { CommentRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { escapeHatch, transformNewComment, @@ -20,7 +21,7 @@ import { } from '../../utils'; import { RouteDeps } from '../../types'; -export function initPostCommentApi({ caseService, router }: RouteDeps) { +export function initPostCommentApi({ caseService, router, userActionService }: RouteDeps) { router.post( { path: '/api/cases/{case_id}/comments', @@ -33,25 +34,28 @@ export function initPostCommentApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CommentRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); - const createdBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newComment = await caseService.postNewComment({ - client: context.core.savedObjects.client, + client, attributes: transformNewComment({ createdDate, ...query, - ...createdBy, + username, + full_name, + email, }), references: [ { @@ -62,16 +66,19 @@ export function initPostCommentApi({ caseService, router }: RouteDeps) { ], }); - const updateCase = { - comment_ids: [...myCase.attributes.comment_ids, newComment.id], - }; - - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: myCase.id, + commentId: newComment.id, + fields: ['comment'], + newValue: query.comment, + }), + ], }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 1542394fc438d..3a1b9d5059cbc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -48,8 +48,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); const updateDate = new Date().toISOString(); const patch = await caseConfigureService.patch({ diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index c839d36dcf4df..2a23abf0cbf21 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -42,8 +42,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ) ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { email, full_name, username } = await caseService.getUser({ request, response }); const creationDate = new Date().toISOString(); const post = await caseConfigureService.post({ diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index 559a477a83a6c..8b0384c12edce 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -5,10 +5,12 @@ */ import { schema } from '@kbn/config-schema'; + +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -export function initDeleteCasesApi({ caseService, router }: RouteDeps) { +export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases', @@ -20,10 +22,11 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; await Promise.all( request.query.ids.map(id => caseService.deleteCase({ - client: context.core.savedObjects.client, + client, caseId: id, }) ) @@ -31,7 +34,7 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { const comments = await Promise.all( request.query.ids.map(id => caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: id, }) ) @@ -43,7 +46,7 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { Promise.all( c.saved_objects.map(({ id }) => caseService.deleteComment({ - client: context.core.savedObjects.client, + client, commentId: id, }) ) @@ -51,6 +54,22 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { ) ); } + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); + + await userActionService.postUserActions({ + client, + actions: request.query.ids.map(id => + buildCaseUserActionItem({ + action: 'create', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + caseId: id, + fields: ['comment', 'description', 'status', 'tags', 'title'], + }) + ), + }); + return response.ok({ body: 'true' }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 76a1992c64270..e7b2044f2badf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -13,7 +13,7 @@ import { identity } from 'fp-ts/lib/function'; import { isEmpty } from 'lodash'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors } from '../../../../common/api'; import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; -import { RouteDeps } from '../types'; +import { RouteDeps, TotalCommentByCase } from '../types'; import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => @@ -97,9 +97,44 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { caseService.findCases(argsOpenCases), caseService.findCases(argsClosedCases), ]); + + const totalCommentsFindByCases = await Promise.all( + cases.saved_objects.map(c => + caseService.getAllCaseComments({ + client, + caseId: c.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }) + ) + ); + + const totalCommentsByCases = totalCommentsFindByCases.reduce( + (acc, itemFind) => { + if (itemFind.saved_objects.length > 0) { + const caseId = + itemFind.saved_objects[0].references.find(r => r.type === CASE_SAVED_OBJECT)?.id ?? + null; + if (caseId != null) { + return [...acc, { caseId, totalComments: itemFind.total }]; + } + } + return [...acc]; + }, + [] + ); + return response.ok({ body: CasesFindResponseRt.encode( - transformCases(cases, openCases.total ?? 0, closesCases.total ?? 0) + transformCases( + cases, + openCases.total ?? 0, + closesCases.total ?? 0, + totalCommentsByCases + ) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 1415513bca346..e947118a39e8e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -25,10 +25,11 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const includeComments = JSON.parse(request.query.includeComments); const theCase = await caseService.getCase({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); @@ -37,7 +38,7 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { } const theComments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 3bf46cadc83c8..747b5195da7ec 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -4,10 +4,57 @@ * you may not use this file except in compliance with the Elastic License. */ -import { difference, get } from 'lodash'; +import { get } from 'lodash'; import { CaseAttributes, CasePatchRequest } from '../../../../common/api'; +interface CompareArrays { + addedItems: string[]; + deletedItems: string[]; +} +export const compareArrays = ({ + originalValue, + updatedValue, +}: { + originalValue: string[]; + updatedValue: string[]; +}): CompareArrays => { + const result: CompareArrays = { + addedItems: [], + deletedItems: [], + }; + originalValue.forEach(origVal => { + if (!updatedValue.includes(origVal)) { + result.deletedItems = [...result.deletedItems, origVal]; + } + }); + updatedValue.forEach(updatedVal => { + if (!originalValue.includes(updatedVal)) { + result.addedItems = [...result.addedItems, updatedVal]; + } + }); + + return result; +}; + +export const isTwoArraysDifference = ( + originalValue: unknown, + updatedValue: unknown +): CompareArrays | null => { + if ( + originalValue != null && + updatedValue != null && + Array.isArray(updatedValue) && + Array.isArray(originalValue) + ) { + const compObj = compareArrays({ originalValue, updatedValue }); + if (compObj.addedItems.length > 0 || compObj.deletedItems.length > 0) { + return compObj; + } + } + return null; +}; + export const getCaseToUpdate = ( currentCase: CaseAttributes, queryCase: CasePatchRequest @@ -15,12 +62,7 @@ export const getCaseToUpdate = ( Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); - if ( - currentValue != null && - Array.isArray(value) && - Array.isArray(currentValue) && - difference(value, currentValue).length !== 0 - ) { + if (isTwoArraysDifference(value, currentValue)) { return { ...acc, [key]: value, diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 19ff7f0734a77..ac1e67cec52bd 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -52,15 +52,16 @@ describe('PATCH cases', () => { { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comment_ids: ['mock-comment-1'], comments: [], created_at: '2019-11-25T21:54:48.952Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', + external_service: null, status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', + totalComment: 0, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', @@ -94,15 +95,16 @@ describe('PATCH cases', () => { { closed_at: null, closed_by: null, - comment_ids: [], comments: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'Oh no, a bad meanie going LOLBins all over the place!', id: 'mock-id-4', + external_service: null, status: 'open', tags: ['LOLBins'], title: 'Another bad one', + totalComment: 0, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 4aa0d8daf5b34..3d0b7bc79f88b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -18,8 +18,9 @@ import { import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; import { getCaseToUpdate } from './helpers'; +import { buildCaseUserActions } from '../../../services/user_actions/helpers'; -export function initPatchCasesApi({ caseService, router }: RouteDeps) { +export function initPatchCasesApi({ caseService, router, userActionService }: RouteDeps) { router.patch( { path: '/api/cases', @@ -29,12 +30,13 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CasesPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const myCases = await caseService.getCases({ - client: context.core.savedObjects.client, + client, caseIds: query.cases.map(q => q.id), }); let nonExistingCases: CasePatchRequest[] = []; @@ -72,11 +74,10 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { return Object.keys(updateCaseAttributes).length > 0; }); if (updateFilterCases.length > 0) { - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ - client: context.core.savedObjects.client, + client, cases: updateFilterCases.map(thisCase => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; let closedInfo = {}; @@ -103,6 +104,7 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }; }), }); + const returnUpdatedCase = myCases.saved_objects .filter(myCase => updatedCases.saved_objects.some(updatedCase => updatedCase.id === myCase.id) @@ -116,6 +118,17 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { references: myCase.references, }); }); + + await userActionService.postUserActions({ + client, + actions: buildCaseUserActions({ + originalCases: myCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { email, full_name, username }, + }), + }); + return response.ok({ body: CasesResponseRt.encode(returnUpdatedCase), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 9e854c3178e1e..75be68013bcd4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -12,9 +12,10 @@ import { identity } from 'fp-ts/lib/function'; import { flattenCaseSavedObject, transformNewCase, wrapError, escapeHatch } from '../utils'; import { CaseRequestRt, throwErrors, CaseResponseRt } from '../../../../common/api'; +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; -export function initPostCaseApi({ caseService, router }: RouteDeps) { +export function initPostCaseApi({ caseService, router, userActionService }: RouteDeps) { router.post( { path: '/api/cases', @@ -24,21 +25,39 @@ export function initPostCaseApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CaseRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const createdBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newCase = await caseService.postNewCase({ - client: context.core.savedObjects.client, + client, attributes: transformNewCase({ createdDate, newCase: query, - ...createdBy, + username, + full_name, + email, }), }); + + await userActionService.postUserActions({ + client, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: newCase.id, + fields: ['description', 'status', 'tags', 'title'], + newValue: JSON.stringify(query), + }), + ], + }); + return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(newCase, [])) }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts new file mode 100644 index 0000000000000..6ae3df180d9e4 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { flattenCaseSavedObject, wrapError, escapeHatch } from '../utils'; + +import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api'; +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; +import { RouteDeps } from '../types'; + +export function initPushCaseUserActionApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { + router.post( + { + path: '/api/cases/{case_id}/_push', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const caseId = request.params.case_id; + const query = pipe( + CaseExternalServiceRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + const { username, full_name, email } = await caseService.getUser({ request, response }); + const pushedDate = new Date().toISOString(); + + const [myCase, myCaseConfigure, totalCommentsFindByCases] = await Promise.all([ + caseService.getCase({ + client, + caseId: request.params.case_id, + }), + caseConfigureService.find({ client }), + caseService.getAllCaseComments({ + client, + caseId, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }), + ]); + + if (myCase.attributes.status === 'closed') { + throw Boom.conflict( + `This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.` + ); + } + + const comments = await caseService.getAllCaseComments({ + client, + caseId, + options: { + fields: [], + page: 1, + perPage: totalCommentsFindByCases.total, + }, + }); + + const externalService = { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + ...query, + }; + + const [updatedCase, updatedComments] = await Promise.all([ + caseService.patchCase({ + client, + caseId, + updatedAttributes: { + ...(myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? { + status: 'closed', + closed_at: pushedDate, + closed_by: { email, full_name, username }, + } + : {}), + external_service: externalService, + updated_at: pushedDate, + updated_by: { username, full_name, email }, + }, + version: myCase.version, + }), + caseService.patchComments({ + client, + comments: comments.saved_objects.map(comment => ({ + commentId: comment.id, + updatedAttributes: { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + updated_at: pushedDate, + updated_by: { username, full_name, email }, + }, + version: comment.version, + })), + }), + userActionService.postUserActions({ + client, + actions: [ + ...(myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? [ + buildCaseUserActionItem({ + action: 'update', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['status'], + newValue: 'closed', + oldValue: myCase.attributes.status, + }), + ] + : []), + buildCaseUserActionItem({ + action: 'push-to-service', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['pushed'], + newValue: JSON.stringify(externalService), + }), + ], + }), + ]); + + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject( + { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + }, + comments.saved_objects.map(origComment => { + const updatedComment = updatedComments.saved_objects.find( + c => c.id === origComment.id + ); + return { + ...origComment, + ...updatedComment, + attributes: { + ...origComment.attributes, + ...updatedComment?.attributes, + }, + version: updatedComment?.version ?? origComment.version, + references: origComment?.references ?? [], + }; + }) + ) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts index 519bb198f5f9e..56862a96e0563 100644 --- a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts @@ -16,8 +16,9 @@ export function initGetReportersApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const reporters = await caseService.getReporters({ - client: context.core.savedObjects.client, + client, }); return response.ok({ body: UsersRt.encode(reporters) }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index b4fc90d702604..f7431729d398c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -18,8 +18,9 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const argsOpenCases = { - client: context.core.savedObjects.client, + client, options: { fields: [], page: 1, @@ -29,7 +30,7 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }; const argsClosedCases = { - client: context.core.savedObjects.client, + client, options: { fields: [], page: 1, diff --git a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts index ca51f421f4f56..55e8fe2af128c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts +++ b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts @@ -15,8 +15,9 @@ export function initGetTagsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const tags = await caseService.getTags({ - client: context.core.savedObjects.client, + client, }); return response.ok({ body: tags }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts new file mode 100644 index 0000000000000..2d4f16e46d561 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { CaseUserActionsResponseRt } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +export function initGetAllUserActionsApi({ userActionService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{case_id}/user_actions', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const userActions = await userActionService.getUserActions({ + client, + caseId: request.params.case_id, + }); + return response.ok({ + body: CaseUserActionsResponseRt.encode( + userActions.saved_objects.map(ua => ({ + ...ua.attributes, + action_id: ua.id, + case_id: ua.references.find(r => r.type === CASE_SAVED_OBJECT)?.id ?? '', + comment_id: ua.references.find(r => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, + })) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 60ee57a0efea7..ced88fabf3160 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -9,6 +9,11 @@ import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; import { initPatchCasesApi } from './cases/patch_cases'; import { initPostCaseApi } from './cases/post_case'; +import { initPushCaseUserActionApi } from './cases/push_case'; +import { initGetReportersApi } from './cases/reporters/get_reporters'; +import { initGetCasesStatusApi } from './cases/status/get_status'; +import { initGetTagsApi } from './cases/tags/get_tags'; +import { initGetAllUserActionsApi } from './cases/user_actions/get_all_user_actions'; import { initDeleteCommentApi } from './cases/comments/delete_comment'; import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments'; @@ -18,18 +23,13 @@ import { initGetCommentApi } from './cases/comments/get_comment'; import { initPatchCommentApi } from './cases/comments/patch_comment'; import { initPostCommentApi } from './cases/comments/post_comment'; -import { initGetReportersApi } from './cases/reporters/get_reporters'; - -import { initGetCasesStatusApi } from './cases/status/get_status'; - -import { initGetTagsApi } from './cases/tags/get_tags'; - -import { RouteDeps } from './types'; import { initCaseConfigureGetActionConnector } from './cases/configure/get_connectors'; import { initGetCaseConfigure } from './cases/configure/get_configure'; import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; +import { RouteDeps } from './types'; + export function initCaseApi(deps: RouteDeps) { // Cases initDeleteCasesApi(deps); @@ -37,6 +37,8 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseApi(deps); initPatchCasesApi(deps); initPostCaseApi(deps); + initPushCaseUserActionApi(deps); + initGetAllUserActionsApi(deps); // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 7af3e7b70d96f..e532a7b618b5c 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -5,11 +5,16 @@ */ import { IRouter } from 'src/core/server'; -import { CaseConfigureServiceSetup, CaseServiceSetup } from '../../services'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; export interface RouteDeps { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; router: IRouter; } @@ -18,3 +23,8 @@ export enum SortFieldCase { createdAt = 'created_at', status = 'status', } + +export interface TotalCommentByCase { + caseId: string; + totalComments: number; +} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 19dbb024d1e0b..9d90eb8ef4a6d 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -22,7 +22,8 @@ import { CommentsResponse, CommentAttributes, } from '../../../common/api'; -import { SortFieldCase } from './types'; + +import { SortFieldCase, TotalCommentByCase } from './types'; export const transformNewCase = ({ createdDate, @@ -37,11 +38,11 @@ export const transformNewCase = ({ newCase: CaseRequest; username: string; }): CaseAttributes => ({ - closed_at: newCase.status === 'closed' ? createdDate : null, - closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, - comment_ids: [], + closed_at: null, + closed_by: null, created_at: createdDate, created_by: { email, full_name, username }, + external_service: null, updated_at: null, updated_by: null, ...newCase, @@ -64,6 +65,8 @@ export const transformNewComment = ({ comment, created_at: createdDate, created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, updated_at: null, updated_by: null, }); @@ -81,30 +84,41 @@ export function wrapError(error: any): CustomHttpResponseOptions export const transformCases = ( cases: SavedObjectsFindResponse, countOpenCases: number, - countClosedCases: number + countClosedCases: number, + totalCommentByCase: TotalCommentByCase[] ): CasesFindResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects), + cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), count_open_cases: countOpenCases, count_closed_cases: countClosedCases, }); export const flattenCaseSavedObjects = ( - savedObjects: SavedObjectsFindResponse['saved_objects'] + savedObjects: SavedObjectsFindResponse['saved_objects'], + totalCommentByCase: TotalCommentByCase[] ): CaseResponse[] => savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { - return [...acc, flattenCaseSavedObject(savedObject, [])]; + return [ + ...acc, + flattenCaseSavedObject( + savedObject, + [], + totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0 + ), + ]; }, []); export const flattenCaseSavedObject = ( savedObject: SavedObject, - comments: Array> = [] + comments: Array> = [], + totalComment: number = 0 ): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), + totalComment, ...savedObject.attributes, }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 8eab040b9ca9c..a4c5dab0feeb7 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -30,9 +30,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, - comment_ids: { - type: 'keyword', - }, created_at: { type: 'date', }, @@ -52,6 +49,41 @@ export const caseSavedObjectType: SavedObjectsType = { description: { type: 'text', }, + external_service: { + properties: { + pushed_at: { + type: 'date', + }, + pushed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + connector_id: { + type: 'keyword', + }, + connector_name: { + type: 'keyword', + }, + external_id: { + type: 'keyword', + }, + external_title: { + type: 'text', + }, + external_url: { + type: 'text', + }, + }, + }, title: { type: 'keyword', }, @@ -61,6 +93,7 @@ export const caseSavedObjectType: SavedObjectsType = { tags: { type: 'keyword', }, + updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index f52da886e7611..8776dd39b11fa 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -33,6 +33,19 @@ export const caseCommentSavedObjectType: SavedObjectsType = { }, }, }, + pushed_at: { + type: 'date', + }, + pushed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/configure.ts b/x-pack/plugins/case/server/saved_object_types/configure.ts index 8ea6f6bba7d4f..d66c38b6ea8ff 100644 --- a/x-pack/plugins/case/server/saved_object_types/configure.ts +++ b/x-pack/plugins/case/server/saved_object_types/configure.ts @@ -19,6 +19,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, created_by: { properties: { + email: { + type: 'keyword', + }, username: { type: 'keyword', }, @@ -30,6 +33,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { connector_id: { type: 'keyword', }, + connector_name: { + type: 'keyword', + }, closure_type: { type: 'keyword', }, @@ -38,6 +44,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, updated_by: { properties: { + email: { + type: 'keyword', + }, username: { type: 'keyword', }, diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts index 978b3d35ee5c6..0e4b9fa3e2eee 100644 --- a/x-pack/plugins/case/server/saved_object_types/index.ts +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -7,3 +7,4 @@ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; +export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; diff --git a/x-pack/plugins/case/server/saved_object_types/user_actions.ts b/x-pack/plugins/case/server/saved_object_types/user_actions.ts new file mode 100644 index 0000000000000..b61bfafc3b33c --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/user_actions.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsType } from 'src/core/server'; + +export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; + +export const caseUserActionSavedObjectType: SavedObjectsType = { + name: CASE_USER_ACTION_SAVED_OBJECT, + hidden: false, + namespaceAgnostic: false, + mappings: { + properties: { + action_field: { + type: 'keyword', + }, + action: { + type: 'keyword', + }, + action_at: { + type: 'date', + }, + action_by: { + properties: { + email: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + new_value: { + type: 'text', + }, + old_value: { + type: 'text', + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 4bbffddf63251..09d726228d309 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -24,11 +24,17 @@ import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; export { CaseConfigureService, CaseConfigureServiceSetup } from './configure'; +export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions'; -interface ClientArgs { +export interface ClientArgs { client: SavedObjectsClientContract; } +interface PushedArgs { + pushed_at: string; + pushed_by: User; +} + interface GetCaseArgs extends ClientArgs { caseId: string; } @@ -37,7 +43,7 @@ interface GetCasesArgs extends ClientArgs { caseIds: string[]; } -interface GetCommentsArgs extends GetCaseArgs { +interface FindCommentsArgs extends GetCaseArgs { options?: SavedObjectFindOptions; } @@ -47,6 +53,7 @@ interface FindCasesArgs extends ClientArgs { interface GetCommentArgs extends ClientArgs { commentId: string; } + interface PostCaseArgs extends ClientArgs { attributes: CaseAttributes; } @@ -58,7 +65,7 @@ interface PostCommentArgs extends ClientArgs { interface PatchCase { caseId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } type PatchCaseArgs = PatchCase & ClientArgs; @@ -68,10 +75,20 @@ interface PatchCasesArgs extends ClientArgs { } interface UpdateCommentArgs extends ClientArgs { commentId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } +interface PatchComment { + commentId: string; + updatedAttributes: Partial; + version?: string; +} + +interface PatchComments extends ClientArgs { + comments: PatchComment[]; +} + interface GetUserArgs { request: KibanaRequest; response: KibanaResponseFactory; @@ -84,7 +101,7 @@ export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; findCases(args: FindCasesArgs): Promise>; - getAllCaseComments(args: GetCommentsArgs): Promise>; + getAllCaseComments(args: FindCommentsArgs): Promise>; getCase(args: GetCaseArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; @@ -96,6 +113,7 @@ export interface CaseServiceSetup { patchCase(args: PatchCaseArgs): Promise>; patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; + patchComments(args: PatchComments): Promise>; } export class CaseService { @@ -157,7 +175,7 @@ export class CaseService { throw error; } }, - getAllCaseComments: async ({ client, caseId, options }: GetCommentsArgs) => { + getAllCaseComments: async ({ client, caseId, options }: FindCommentsArgs) => { try { this.log.debug(`Attempting to GET all comments for case ${caseId}`); return await client.find({ @@ -261,5 +279,25 @@ export class CaseService { throw error; } }, + patchComments: async ({ client, comments }: PatchComments) => { + try { + this.log.debug( + `Attempting to UPDATE comments ${comments.map(c => c.commentId).join(', ')}` + ); + return await client.bulkUpdate( + comments.map(c => ({ + type: CASE_COMMENT_SAVED_OBJECT, + id: c.commentId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE comments ${comments.map(c => c.commentId).join(', ')}: ${error}` + ); + throw error; + } + }, }); } diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts new file mode 100644 index 0000000000000..59d193f0f30d5 --- /dev/null +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectsUpdateResponse } from 'kibana/server'; +import { get } from 'lodash'; + +import { + CaseUserActionAttributes, + UserAction, + UserActionField, + CaseAttributes, + User, +} from '../../../common/api'; +import { isTwoArraysDifference } from '../../routes/api/cases/helpers'; +import { UserActionItem } from '.'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; + +export const transformNewUserAction = ({ + actionField, + action, + actionAt, + email, + full_name, + newValue = null, + oldValue = null, + username, +}: { + actionField: UserActionField; + action: UserAction; + actionAt: string; + email?: string; + full_name?: string; + newValue?: string | null; + oldValue?: string | null; + username: string; +}): CaseUserActionAttributes => ({ + action_field: actionField, + action, + action_at: actionAt, + action_by: { email, full_name, username }, + new_value: newValue, + old_value: oldValue, +}); + +interface BuildCaseUserAction { + action: UserAction; + actionAt: string; + actionBy: User; + caseId: string; + fields: UserActionField | unknown[]; + newValue?: string | unknown; + oldValue?: string | unknown; +} + +interface BuildCommentUserActionItem extends BuildCaseUserAction { + commentId: string; +} + +export const buildCommentUserActionItem = ({ + action, + actionAt, + actionBy, + caseId, + commentId, + fields, + newValue, + oldValue, +}: BuildCommentUserActionItem): UserActionItem => ({ + attributes: transformNewUserAction({ + actionField: fields as UserActionField, + action, + actionAt, + ...actionBy, + newValue: newValue as string, + oldValue: oldValue as string, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + { + type: CASE_COMMENT_SAVED_OBJECT, + name: `associated-${CASE_COMMENT_SAVED_OBJECT}`, + id: commentId, + }, + ], +}); + +export const buildCaseUserActionItem = ({ + action, + actionAt, + actionBy, + caseId, + fields, + newValue, + oldValue, +}: BuildCaseUserAction): UserActionItem => ({ + attributes: transformNewUserAction({ + actionField: fields as UserActionField, + action, + actionAt, + ...actionBy, + newValue: newValue as string, + oldValue: oldValue as string, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], +}); + +const userActionFieldsAllowed: UserActionField = [ + 'comment', + 'description', + 'tags', + 'title', + 'status', +]; + +export const buildCaseUserActions = ({ + actionDate, + actionBy, + originalCases, + updatedCases, +}: { + actionDate: string; + actionBy: User; + originalCases: Array>; + updatedCases: Array>; +}): UserActionItem[] => + updatedCases.reduce((acc, updatedItem) => { + const originalItem = originalCases.find(oItem => oItem.id === updatedItem.id); + if (originalItem != null) { + let userActions: UserActionItem[] = []; + const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; + updatedFields.forEach(field => { + if (userActionFieldsAllowed.includes(field)) { + const origValue = get(originalItem, ['attributes', field]); + const updatedValue = get(updatedItem, ['attributes', field]); + const compareValues = isTwoArraysDifference(origValue, updatedValue); + if (compareValues != null) { + if (compareValues.addedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'add', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.addedItems.join(', '), + }), + ]; + } + if (compareValues.deletedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'delete', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.deletedItems.join(', '), + }), + ]; + } + } else if (origValue !== updatedValue) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'update', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: updatedValue, + oldValue: origValue, + }), + ]; + } + } + }); + return [...acc, ...userActions]; + } + return acc; + }, []); diff --git a/x-pack/plugins/case/server/services/user_actions/index.ts b/x-pack/plugins/case/server/services/user_actions/index.ts new file mode 100644 index 0000000000000..0e9babf9d81af --- /dev/null +++ b/x-pack/plugins/case/server/services/user_actions/index.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + SavedObjectsFindResponse, + Logger, + SavedObjectsBulkResponse, + SavedObjectReference, +} from 'kibana/server'; + +import { CaseUserActionAttributes } from '../../../common/api'; +import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { ClientArgs } from '..'; + +interface GetCaseUserActionArgs extends ClientArgs { + caseId: string; +} + +export interface UserActionItem { + attributes: CaseUserActionAttributes; + references: SavedObjectReference[]; +} + +interface PostCaseUserActionArgs extends ClientArgs { + actions: UserActionItem[]; +} + +export interface CaseUserActionServiceSetup { + getUserActions( + args: GetCaseUserActionArgs + ): Promise>; + postUserActions( + args: PostCaseUserActionArgs + ): Promise>; +} + +export class CaseUserActionService { + constructor(private readonly log: Logger) {} + public setup = async (): Promise => ({ + getUserActions: async ({ client, caseId }: GetCaseUserActionArgs) => { + try { + const caseUserActionInfo = await client.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + fields: [], + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: 1, + }); + return await client.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: caseUserActionInfo.total, + sortField: 'action_at', + sortOrder: 'asc', + }); + } catch (error) { + this.log.debug(`Error on GET case user action: ${error}`); + throw error; + } + }, + postUserActions: async ({ client, actions }: PostCaseUserActionArgs) => { + try { + this.log.debug(`Attempting to POST a new case user action`); + return await client.bulkCreate( + actions.map(action => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) + ); + } catch (error) { + this.log.debug(`Error on POST a new case user action: ${error}`); + throw error; + } + }, + }); +} From 35b222a8401b4c429fc6eb0fb847ecb65f571cca Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 24 Mar 2020 02:56:04 +0000 Subject: [PATCH 08/64] fix(NA): log rotation watchers usage (#60956) * fix(NA): log rotation watchers usage * docs(NA): add old value to the example * chore(NA): change warning messages --- docs/setup/settings.asciidoc | 4 ++-- src/legacy/server/config/schema.js | 4 ++-- .../server/logging/rotate/log_rotator.test.ts | 10 ++++++---- src/legacy/server/logging/rotate/log_rotator.ts | 16 +++++++++++++--- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 71bb7b81ea420..a72c15190840a 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -193,7 +193,7 @@ that feature would not take any effect. `logging.rotate.everyBytes:`:: [experimental] *Default: 10485760* The maximum size of a log file (that is `not an exact` limit). After the limit is reached, a new log file is generated. The default size limit is 10485760 (10 MB) and -this option should be in the range of 102400 (100KB) to 1073741824 (1GB). +this option should be in the range of 1048576 (1 MB) to 1073741824 (1 GB). `logging.rotate.keepFiles:`:: [experimental] *Default: 7* The number of most recent rotated log files to keep on disk. Older files are deleted during log rotation. The default value is 7. The `logging.rotate.keepFiles` @@ -203,7 +203,7 @@ option has to be in the range of 2 to 1024 files. the `logging.rotate.usePolling` is enabled. That option has to be in the range of 5000 to 3600000 milliseconds. `logging.rotate.usePolling:`:: [experimental] *Default: false* By default we try to understand the best way to monitoring -the log file. However, there is some systems where it could not be always accurate. In those cases, if needed, +the log file and warning about it. Please be aware there are some systems where watch api is not accurate. In those cases, in order to get the feature working, the `polling` method could be used enabling that option. `logging.silent:`:: *Default: false* Set the value of this setting to `true` to diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index a24ffcbaaa49f..769d9ba311281 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -133,8 +133,8 @@ export default () => .keys({ enabled: Joi.boolean().default(false), everyBytes: Joi.number() - // > 100KB - .greater(102399) + // > 1MB + .greater(1048576) // < 1GB .less(1073741825) // 10MB diff --git a/src/legacy/server/logging/rotate/log_rotator.test.ts b/src/legacy/server/logging/rotate/log_rotator.test.ts index c2100546364d4..70842d42f5e1f 100644 --- a/src/legacy/server/logging/rotate/log_rotator.test.ts +++ b/src/legacy/server/logging/rotate/log_rotator.test.ts @@ -204,8 +204,8 @@ describe('LogRotator', () => { expect(logRotator.running).toBe(true); expect(logRotator.usePolling).toBe(false); - const usePolling = await logRotator._shouldUsePolling(); - expect(usePolling).toBe(false); + const shouldUsePolling = await logRotator._shouldUsePolling(); + expect(shouldUsePolling).toBe(false); await logRotator.stop(); }); @@ -231,7 +231,8 @@ describe('LogRotator', () => { await logRotator.start(); expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(true); + expect(logRotator.usePolling).toBe(false); + expect(logRotator.shouldUsePolling).toBe(true); await logRotator.stop(); }); @@ -257,7 +258,8 @@ describe('LogRotator', () => { await logRotator.start(); expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(true); + expect(logRotator.usePolling).toBe(false); + expect(logRotator.shouldUsePolling).toBe(true); await logRotator.stop(); jest.useRealTimers(); diff --git a/src/legacy/server/logging/rotate/log_rotator.ts b/src/legacy/server/logging/rotate/log_rotator.ts index 3662910ca5a7b..eeb91fd0f2636 100644 --- a/src/legacy/server/logging/rotate/log_rotator.ts +++ b/src/legacy/server/logging/rotate/log_rotator.ts @@ -50,6 +50,7 @@ export class LogRotator { public usePolling: boolean; public pollingInterval: number; private stalkerUsePollingPolicyTestTimeout: NodeJS.Timeout | null; + public shouldUsePolling: boolean; constructor(config: KibanaConfig, server: Server) { this.config = config; @@ -64,6 +65,7 @@ export class LogRotator { this.stalker = null; this.usePolling = config.get('logging.rotate.usePolling'); this.pollingInterval = config.get('logging.rotate.pollingInterval'); + this.shouldUsePolling = false; this.stalkerUsePollingPolicyTestTimeout = null; } @@ -150,12 +152,20 @@ export class LogRotator { } async _startLogFileSizeMonitor() { - this.usePolling = await this._shouldUsePolling(); + this.usePolling = this.config.get('logging.rotate.usePolling'); + this.shouldUsePolling = await this._shouldUsePolling(); - if (this.usePolling && this.usePolling !== this.config.get('logging.rotate.usePolling')) { + if (this.usePolling && !this.shouldUsePolling) { this.log( ['warning', 'logging:rotate'], - 'The current environment does not support `fs.watch`. Falling back to polling using `fs.watchFile`' + 'Looks like your current environment support a faster algorithm then polling. You can try to disable `usePolling`' + ); + } + + if (!this.usePolling && this.shouldUsePolling) { + this.log( + ['error', 'logging:rotate'], + 'Looks like within your current environment you need to use polling in order to enable log rotator. Please enable `usePolling`' ); } From 6de7f2a62b2b078b703bbe6f18475909e1224f57 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 23 Mar 2020 22:26:15 -0700 Subject: [PATCH 09/64] Revert "[APM] Collect telemetry about data/API performance (#51612)" This reverts commit 13baa5156151af258249afc6468f15b53fffeee0. --- src/dev/run_check_lockfile_symlinks.js | 2 - x-pack/legacy/plugins/apm/index.ts | 19 +- x-pack/legacy/plugins/apm/mappings.json | 773 +----------------- x-pack/legacy/plugins/apm/scripts/.gitignore | 1 - .../legacy/plugins/apm/scripts/package.json | 10 - .../apm/scripts/setup-kibana-security.js | 1 - .../apm/scripts/upload-telemetry-data.js | 21 - .../download-telemetry-template.ts | 26 - .../generate-sample-documents.ts | 124 --- .../scripts/upload-telemetry-data/index.ts | 208 ----- .../elasticsearch_fieldnames.test.ts.snap | 48 +- x-pack/plugins/apm/common/agent_name.ts | 44 +- .../apm/common/apm_saved_object_constants.ts | 10 +- .../common/elasticsearch_fieldnames.test.ts | 15 +- .../apm/common/elasticsearch_fieldnames.ts | 11 +- x-pack/plugins/apm/kibana.json | 7 +- x-pack/plugins/apm/server/index.ts | 6 +- .../lib/apm_telemetry/__test__/index.test.ts | 83 ++ .../collect_data_telemetry/index.ts | 77 -- .../collect_data_telemetry/tasks.ts | 725 ---------------- .../apm/server/lib/apm_telemetry/index.ts | 155 +--- .../apm/server/lib/apm_telemetry/types.ts | 118 --- .../server/lib/helpers/setup_request.test.ts | 13 - x-pack/plugins/apm/server/plugin.ts | 37 +- .../server/routes/create_api/index.test.ts | 1 - x-pack/plugins/apm/server/routes/services.ts | 16 + .../apm/typings/elasticsearch/aggregations.ts | 26 +- .../apm/typings/es_schemas/raw/error_raw.ts | 2 - .../typings/es_schemas/raw/fields/observer.ts | 10 - .../apm/typings/es_schemas/raw/span_raw.ts | 2 - .../typings/es_schemas/raw/transaction_raw.ts | 2 - 31 files changed, 206 insertions(+), 2387 deletions(-) delete mode 100644 x-pack/legacy/plugins/apm/scripts/.gitignore delete mode 100644 x-pack/legacy/plugins/apm/scripts/package.json delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/types.ts delete mode 100644 x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts diff --git a/src/dev/run_check_lockfile_symlinks.js b/src/dev/run_check_lockfile_symlinks.js index 6c6fc54638ee8..54a8cdf638a78 100644 --- a/src/dev/run_check_lockfile_symlinks.js +++ b/src/dev/run_check_lockfile_symlinks.js @@ -36,8 +36,6 @@ const IGNORE_FILE_GLOBS = [ '**/*fixtures*/**/*', // cypress isn't used in production, ignore it 'x-pack/legacy/plugins/apm/e2e/*', - // apm scripts aren't used in production, ignore them - 'x-pack/legacy/plugins/apm/scripts/*', ]; run(async ({ log }) => { diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 594e8a4a7af72..0107997f233fe 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -14,13 +14,7 @@ import mappings from './mappings.json'; export const apm: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ - require: [ - 'kibana', - 'elasticsearch', - 'xpack_main', - 'apm_oss', - 'task_manager' - ], + require: ['kibana', 'elasticsearch', 'xpack_main', 'apm_oss'], id: 'apm', configPrefix: 'xpack.apm', publicDir: resolve(__dirname, 'public'), @@ -77,10 +71,7 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true), - - // telemetry - telemetryCollectionEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true) }).default(); }, @@ -116,12 +107,10 @@ export const apm: LegacyPluginInitializer = kibana => { } } }); + const apmPlugin = server.newPlatform.setup.plugins .apm as APMPluginContract; - - apmPlugin.registerLegacyAPI({ - server - }); + apmPlugin.registerLegacyAPI({ server }); } }); }; diff --git a/x-pack/legacy/plugins/apm/mappings.json b/x-pack/legacy/plugins/apm/mappings.json index ba4c7a89ceaa8..61bc90da28756 100644 --- a/x-pack/legacy/plugins/apm/mappings.json +++ b/x-pack/legacy/plugins/apm/mappings.json @@ -1,659 +1,20 @@ { - "apm-telemetry": { + "apm-services-telemetry": { "properties": { - "agents": { - "properties": { - "dotnet": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "name": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "go": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "java": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "type": "object" - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "js-base": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "type": "object" - }, - "language": { - "properties": { - "name": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "type": "object" - } - } - } - } - }, - "nodejs": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "name": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "python": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "ruby": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "rum-js": { - "properties": { - "agent": { - "type": "object" - }, - "service": { - "properties": { - "framework": { - "type": "object" - }, - "language": { - "type": "object" - }, - "runtime": { - "type": "object" - } - } - } - } - } - } - }, - "counts": { - "properties": { - "agent_configuration": { - "properties": { - "all": { - "type": "long" - } - } - }, - "error": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "max_error_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "max_transaction_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "services": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "sourcemap": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "span": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "traces": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - } - } - }, - "cardinality": { - "properties": { - "user_agent": { - "properties": { - "original": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - }, - "transaction": { - "properties": { - "name": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - } - } - }, "has_any_services": { "type": "boolean" }, - "indices": { - "properties": { - "all": { - "properties": { - "total": { - "properties": { - "docs": { - "properties": { - "count": { - "type": "long" - } - } - }, - "store": { - "properties": { - "size_in_bytes": { - "type": "long" - } - } - } - } - } - } - }, - "shards": { - "properties": { - "total": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "ml": { - "properties": { - "all_jobs_count": { - "type": "long" - } - } - } - } - }, - "retainment": { - "properties": { - "error": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "span": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, "services_per_agent": { "properties": { - "dotnet": { + "python": { "type": "long", "null_value": 0 }, - "go": { + "java": { "type": "long", "null_value": 0 }, - "java": { + "nodejs": { "type": "long", "null_value": 0 }, @@ -661,11 +22,11 @@ "type": "long", "null_value": 0 }, - "nodejs": { + "rum-js": { "type": "long", "null_value": 0 }, - "python": { + "dotnet": { "type": "long", "null_value": 0 }, @@ -673,131 +34,11 @@ "type": "long", "null_value": 0 }, - "rum-js": { + "go": { "type": "long", "null_value": 0 } } - }, - "tasks": { - "properties": { - "agent_configuration": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "agents": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "cardinality": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "groupings": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "indices_stats": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "processor_events": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "services": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "versions": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - } - } - }, - "version": { - "properties": { - "apm_server": { - "properties": { - "major": { - "type": "long" - }, - "minor": { - "type": "long" - }, - "patch": { - "type": "long" - } - } - } - } } } }, diff --git a/x-pack/legacy/plugins/apm/scripts/.gitignore b/x-pack/legacy/plugins/apm/scripts/.gitignore deleted file mode 100644 index 8ee01d321b721..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/.gitignore +++ /dev/null @@ -1 +0,0 @@ -yarn.lock diff --git a/x-pack/legacy/plugins/apm/scripts/package.json b/x-pack/legacy/plugins/apm/scripts/package.json deleted file mode 100644 index 9121449c53619..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "apm-scripts", - "version": "1.0.0", - "main": "index.js", - "license": "MIT", - "dependencies": { - "@octokit/rest": "^16.35.0", - "console-stamp": "^0.2.9" - } -} diff --git a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js index 61ba2fdc7f7e3..825c1a526fcc5 100644 --- a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js +++ b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js @@ -16,7 +16,6 @@ ******************************/ // compile typescript on the fly -// eslint-disable-next-line import/no-extraneous-dependencies require('@babel/register')({ extensions: ['.ts'], plugins: ['@babel/plugin-proposal-optional-chaining'], diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js deleted file mode 100644 index a99651c62dd7a..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// compile typescript on the fly -// eslint-disable-next-line import/no-extraneous-dependencies -require('@babel/register')({ - extensions: ['.ts'], - plugins: [ - '@babel/plugin-proposal-optional-chaining', - '@babel/plugin-proposal-nullish-coalescing-operator' - ], - presets: [ - '@babel/typescript', - ['@babel/preset-env', { targets: { node: 'current' } }] - ] -}); - -require('./upload-telemetry-data/index.ts'); diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts deleted file mode 100644 index dfed9223ef708..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// @ts-ignore -import { Octokit } from '@octokit/rest'; - -export async function downloadTelemetryTemplate(octokit: Octokit) { - const file = await octokit.repos.getContents({ - owner: 'elastic', - repo: 'telemetry', - path: 'config/templates/xpack-phone-home.json', - // @ts-ignore - mediaType: { - format: 'application/vnd.github.VERSION.raw' - } - }); - - if (Array.isArray(file.data)) { - throw new Error('Expected single response, got array'); - } - - return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()); -} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts deleted file mode 100644 index 8d76063a7fdf6..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { DeepPartial } from 'utility-types'; -import { - merge, - omit, - defaultsDeep, - range, - mapValues, - isPlainObject, - flatten -} from 'lodash'; -import uuid from 'uuid'; -import { - CollectTelemetryParams, - collectDataTelemetry - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; - -interface GenerateOptions { - days: number; - instances: number; - variation: { - min: number; - max: number; - }; -} - -const randomize = ( - value: unknown, - instanceVariation: number, - dailyGrowth: number -) => { - if (typeof value === 'boolean') { - return Math.random() > 0.5; - } - if (typeof value === 'number') { - return Math.round(instanceVariation * dailyGrowth * value); - } - return value; -}; - -const mapValuesDeep = ( - obj: Record, - iterator: (value: unknown, key: string, obj: Record) => unknown -): Record => - mapValues(obj, (val, key) => - isPlainObject(val) ? mapValuesDeep(val, iterator) : iterator(val, key!, obj) - ); - -export async function generateSampleDocuments( - options: DeepPartial & { - collectTelemetryParams: CollectTelemetryParams; - } -) { - const { collectTelemetryParams, ...preferredOptions } = options; - - const opts: GenerateOptions = defaultsDeep( - { - days: 100, - instances: 50, - variation: { - min: 0.1, - max: 4 - } - }, - preferredOptions - ); - - const sample = await collectDataTelemetry(collectTelemetryParams); - - console.log('Collected telemetry'); // eslint-disable-line no-console - console.log('\n' + JSON.stringify(sample, null, 2)); // eslint-disable-line no-console - - const dateOfScriptExecution = new Date(); - - return flatten( - range(0, opts.instances).map(instanceNo => { - const instanceId = uuid.v4(); - const defaults = { - cluster_uuid: instanceId, - stack_stats: { - kibana: { - versions: { - version: '8.0.0' - } - } - } - }; - - const instanceVariation = - Math.random() * (opts.variation.max - opts.variation.min) + - opts.variation.min; - - return range(0, opts.days).map(dayNo => { - const dailyGrowth = Math.pow(1.005, opts.days - 1 - dayNo); - - const timestamp = Date.UTC( - dateOfScriptExecution.getFullYear(), - dateOfScriptExecution.getMonth(), - -dayNo - ); - - const generated = mapValuesDeep(omit(sample, 'versions'), value => - randomize(value, instanceVariation, dailyGrowth) - ); - - return merge({}, defaults, { - timestamp, - stack_stats: { - kibana: { - plugins: { - apm: merge({}, sample, generated) - } - } - } - }); - }); - }) - ); -} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts deleted file mode 100644 index bdc57eac412fc..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// This script downloads the telemetry mapping, runs the APM telemetry tasks, -// generates a bunch of randomized data based on the downloaded sample, -// and uploads it to a cluster of your choosing in the same format as it is -// stored in the telemetry cluster. Its purpose is twofold: -// - Easier testing of the telemetry tasks -// - Validate whether we can run the queries we want to on the telemetry data - -import fs from 'fs'; -import path from 'path'; -// @ts-ignore -import { Octokit } from '@octokit/rest'; -import { merge, chunk, flatten, pick, identity } from 'lodash'; -import axios from 'axios'; -import yaml from 'js-yaml'; -import { Client } from 'elasticsearch'; -import { argv } from 'yargs'; -import { promisify } from 'util'; -import { Logger } from 'kibana/server'; -// @ts-ignore -import consoleStamp from 'console-stamp'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CollectTelemetryParams } from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; -import { downloadTelemetryTemplate } from './download-telemetry-template'; -import mapping from '../../mappings.json'; -import { generateSampleDocuments } from './generate-sample-documents'; - -consoleStamp(console, '[HH:MM:ss.l]'); - -const githubToken = process.env.GITHUB_TOKEN; - -if (!githubToken) { - throw new Error('GITHUB_TOKEN was not provided.'); -} - -const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); -const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); -const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); - -const xpackTelemetryIndexName = 'xpack-phone-home'; - -const loadedKibanaConfig = (yaml.safeLoad( - fs.readFileSync( - fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, - 'utf8' - ) -) || {}) as {}; - -const cliEsCredentials = pick( - { - 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, - 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, - 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST - }, - identity -) as { - 'elasticsearch.username': string; - 'elasticsearch.password': string; - 'elasticsearch.hosts': string; -}; - -const config = { - 'apm_oss.transactionIndices': 'apm-*', - 'apm_oss.metricsIndices': 'apm-*', - 'apm_oss.errorIndices': 'apm-*', - 'apm_oss.spanIndices': 'apm-*', - 'apm_oss.onboardingIndices': 'apm-*', - 'apm_oss.sourcemapIndices': 'apm-*', - 'elasticsearch.hosts': 'http://localhost:9200', - ...loadedKibanaConfig, - ...cliEsCredentials -}; - -async function uploadData() { - const octokit = new Octokit({ - auth: githubToken - }); - - const telemetryTemplate = await downloadTelemetryTemplate(octokit); - - const kibanaMapping = mapping['apm-telemetry']; - - const httpAuth = - config['elasticsearch.username'] && config['elasticsearch.password'] - ? { - username: config['elasticsearch.username'], - password: config['elasticsearch.password'] - } - : null; - - const client = new Client({ - host: config['elasticsearch.hosts'], - ...(httpAuth - ? { - httpAuth: `${httpAuth.username}:${httpAuth.password}` - } - : {}) - }); - - if (argv.clear) { - try { - await promisify(client.indices.delete.bind(client))({ - index: xpackTelemetryIndexName - }); - } catch (err) { - // 404 = index not found, totally okay - if (err.status !== 404) { - throw err; - } - } - } - - const axiosInstance = axios.create({ - baseURL: config['elasticsearch.hosts'], - ...(httpAuth ? { auth: httpAuth } : {}) - }); - - const newTemplate = merge(telemetryTemplate, { - settings: { - index: { mapping: { total_fields: { limit: 10000 } } } - } - }); - - // override apm mapping instead of merging - newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; - - await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); - - const sampleDocuments = await generateSampleDocuments({ - collectTelemetryParams: { - logger: (console as unknown) as Logger, - indices: { - ...config, - apmCustomLinkIndex: '.apm-custom-links', - apmAgentConfigurationIndex: '.apm-agent-configuration' - }, - search: body => { - return promisify(client.search.bind(client))({ - ...body, - requestTimeout: 120000 - }) as any; - }, - indicesStats: body => { - return promisify(client.indices.stats.bind(client))({ - ...body, - requestTimeout: 120000 - }) as any; - }, - transportRequest: (params => { - return axiosInstance[params.method](params.path); - }) as CollectTelemetryParams['transportRequest'] - } - }); - - const chunks = chunk(sampleDocuments, 250); - - await chunks.reduce>((prev, documents) => { - return prev.then(async () => { - const body = flatten( - documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) - ); - - return promisify(client.bulk.bind(client))({ - body, - refresh: true - }).then((response: any) => { - if (response.errors) { - const firstError = response.items.filter( - (item: any) => item.index.status >= 400 - )[0].index.error; - throw new Error(`Failed to upload documents: ${firstError.reason} `); - } - }); - }); - }, Promise.resolve()); -} - -uploadData() - .catch(e => { - if ('response' in e) { - if (typeof e.response === 'string') { - // eslint-disable-next-line no-console - console.log(e.response); - } else { - // eslint-disable-next-line no-console - console.log( - JSON.stringify( - e.response, - ['status', 'statusText', 'headers', 'data'], - 2 - ) - ); - } - } else { - // eslint-disable-next-line no-console - console.log(e); - } - process.exit(1); - }) - .then(() => { - // eslint-disable-next-line no-console - console.log('Finished uploading generated telemetry data'); - }); diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 5de82a9ee8788..897d4e979fce3 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -2,8 +2,6 @@ exports[`Error AGENT_NAME 1`] = `"java"`; -exports[`Error AGENT_VERSION 1`] = `"agent version"`; - exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; @@ -58,7 +56,7 @@ exports[`Error METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Error OBSERVER_LISTENING 1`] = `undefined`; -exports[`Error OBSERVER_VERSION_MAJOR 1`] = `8`; +exports[`Error OBSERVER_VERSION_MAJOR 1`] = `undefined`; exports[`Error PARENT_ID 1`] = `"parentId"`; @@ -70,20 +68,10 @@ exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; -exports[`Error SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; - -exports[`Error SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; - -exports[`Error SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; - exports[`Error SERVICE_NAME 1`] = `"service name"`; exports[`Error SERVICE_NODE_NAME 1`] = `undefined`; -exports[`Error SERVICE_RUNTIME_NAME 1`] = `undefined`; - -exports[`Error SERVICE_RUNTIME_VERSION 1`] = `undefined`; - exports[`Error SERVICE_VERSION 1`] = `undefined`; exports[`Error SPAN_ACTION 1`] = `undefined`; @@ -124,14 +112,10 @@ exports[`Error URL_FULL 1`] = `undefined`; exports[`Error USER_AGENT_NAME 1`] = `undefined`; -exports[`Error USER_AGENT_ORIGINAL 1`] = `undefined`; - exports[`Error USER_ID 1`] = `undefined`; exports[`Span AGENT_NAME 1`] = `"java"`; -exports[`Span AGENT_VERSION 1`] = `"agent version"`; - exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; @@ -186,7 +170,7 @@ exports[`Span METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Span OBSERVER_LISTENING 1`] = `undefined`; -exports[`Span OBSERVER_VERSION_MAJOR 1`] = `8`; +exports[`Span OBSERVER_VERSION_MAJOR 1`] = `undefined`; exports[`Span PARENT_ID 1`] = `"parentId"`; @@ -198,20 +182,10 @@ exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; -exports[`Span SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; - -exports[`Span SERVICE_LANGUAGE_NAME 1`] = `undefined`; - -exports[`Span SERVICE_LANGUAGE_VERSION 1`] = `undefined`; - exports[`Span SERVICE_NAME 1`] = `"service name"`; exports[`Span SERVICE_NODE_NAME 1`] = `undefined`; -exports[`Span SERVICE_RUNTIME_NAME 1`] = `undefined`; - -exports[`Span SERVICE_RUNTIME_VERSION 1`] = `undefined`; - exports[`Span SERVICE_VERSION 1`] = `undefined`; exports[`Span SPAN_ACTION 1`] = `"my action"`; @@ -252,14 +226,10 @@ exports[`Span URL_FULL 1`] = `undefined`; exports[`Span USER_AGENT_NAME 1`] = `undefined`; -exports[`Span USER_AGENT_ORIGINAL 1`] = `undefined`; - exports[`Span USER_ID 1`] = `undefined`; exports[`Transaction AGENT_NAME 1`] = `"java"`; -exports[`Transaction AGENT_VERSION 1`] = `"agent version"`; - exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; @@ -314,7 +284,7 @@ exports[`Transaction METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`; -exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `8`; +exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `undefined`; exports[`Transaction PARENT_ID 1`] = `"parentId"`; @@ -326,20 +296,10 @@ exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; -exports[`Transaction SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; - -exports[`Transaction SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; - -exports[`Transaction SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; - exports[`Transaction SERVICE_NAME 1`] = `"service name"`; exports[`Transaction SERVICE_NODE_NAME 1`] = `undefined`; -exports[`Transaction SERVICE_RUNTIME_NAME 1`] = `undefined`; - -exports[`Transaction SERVICE_RUNTIME_VERSION 1`] = `undefined`; - exports[`Transaction SERVICE_VERSION 1`] = `undefined`; exports[`Transaction SPAN_ACTION 1`] = `undefined`; @@ -380,6 +340,4 @@ exports[`Transaction URL_FULL 1`] = `"http://www.elastic.co"`; exports[`Transaction USER_AGENT_NAME 1`] = `"Other"`; -exports[`Transaction USER_AGENT_ORIGINAL 1`] = `"test original"`; - exports[`Transaction USER_ID 1`] = `"1337"`; diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index 085828b729ea5..bb68eb88b8e18 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -4,40 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AgentName } from '../typings/es_schemas/ui/fields/agent'; - /* * Agent names can be any string. This list only defines the official agents * that we might want to target specifically eg. linking to their documentation * & telemetry reporting. Support additional agent types by appending * definitions in mappings.json (for telemetry), the AgentName type, and the - * AGENT_NAMES array. + * agentNames object. */ +import { AgentName } from '../typings/es_schemas/ui/fields/agent'; -export const AGENT_NAMES: AgentName[] = [ - 'java', - 'js-base', - 'rum-js', - 'dotnet', - 'go', - 'java', - 'nodejs', - 'python', - 'ruby' -]; +const agentNames: { [agentName in AgentName]: agentName } = { + python: 'python', + java: 'java', + nodejs: 'nodejs', + 'js-base': 'js-base', + 'rum-js': 'rum-js', + dotnet: 'dotnet', + ruby: 'ruby', + go: 'go' +}; -export function isAgentName(agentName: string): agentName is AgentName { - return AGENT_NAMES.includes(agentName as AgentName); +export function isAgentName(agentName: string): boolean { + return Object.values(agentNames).includes(agentName as AgentName); } -export function isRumAgentName( - agentName: string | undefined -): agentName is 'js-base' | 'rum-js' { - return agentName === 'js-base' || agentName === 'rum-js'; +export function isRumAgentName(agentName: string | undefined) { + return ( + agentName === agentNames['js-base'] || agentName === agentNames['rum-js'] + ); } -export function isJavaAgentName( - agentName: string | undefined -): agentName is 'java' { - return agentName === 'java'; +export function isJavaAgentName(agentName: string | undefined) { + return agentName === agentNames.java; } diff --git a/x-pack/plugins/apm/common/apm_saved_object_constants.ts b/x-pack/plugins/apm/common/apm_saved_object_constants.ts index 0529d90fe940a..ac43b700117c6 100644 --- a/x-pack/plugins/apm/common/apm_saved_object_constants.ts +++ b/x-pack/plugins/apm/common/apm_saved_object_constants.ts @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -// the types have to match the names of the saved object mappings -// in /x-pack/legacy/plugins/apm/mappings.json +// APM Services telemetry +export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE = + 'apm-services-telemetry'; +export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID = 'apm-services-telemetry'; // APM indices export const APM_INDICES_SAVED_OBJECT_TYPE = 'apm-indices'; export const APM_INDICES_SAVED_OBJECT_ID = 'apm-indices'; - -// APM telemetry -export const APM_TELEMETRY_SAVED_OBJECT_TYPE = 'apm-telemetry'; -export const APM_TELEMETRY_SAVED_OBJECT_ID = 'apm-telemetry'; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts index 63fa749cd9f2c..1add2427d16a0 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts @@ -15,10 +15,7 @@ describe('Transaction', () => { const transaction: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: { - version: 'whatever', - version_major: 8 - }, + observer: 'whatever', agent: { name: 'java', version: 'agent version' @@ -66,10 +63,7 @@ describe('Span', () => { const span: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: { - version: 'whatever', - version_major: 8 - }, + observer: 'whatever', agent: { name: 'java', version: 'agent version' @@ -113,10 +107,7 @@ describe('Span', () => { describe('Error', () => { const errorDoc: AllowUnknownProperties = { '@metadata': 'whatever', - observer: { - version: 'whatever', - version_major: 8 - }, + observer: 'whatever', agent: { name: 'java', version: 'agent version' diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index bc1b346f50da7..822201baddd88 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -4,24 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +export const AGENT_NAME = 'agent.name'; export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; -export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version'; -export const SERVICE_LANGUAGE_NAME = 'service.language.name'; -export const SERVICE_LANGUAGE_VERSION = 'service.language.version'; -export const SERVICE_RUNTIME_NAME = 'service.runtime.name'; -export const SERVICE_RUNTIME_VERSION = 'service.runtime.version'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; - -export const AGENT_NAME = 'agent.name'; -export const AGENT_VERSION = 'agent.version'; - export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; -export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; export const DESTINATION_ADDRESS = 'destination.address'; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index dadb1dff6d7a9..96579377c95e8 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -3,11 +3,8 @@ "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": [ - "xpack", - "apm" - ], + "configPath": ["xpack", "apm"], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection", "taskManager"] + "optionalPlugins": ["cloud", "usageCollection"] } diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 77655568a7e9c..8afdb9e99c1a3 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -29,8 +29,7 @@ export const config = { enabled: schema.boolean({ defaultValue: true }), transactionGroupBucketSize: schema.number({ defaultValue: 100 }), maxTraceItems: schema.number({ defaultValue: 1000 }) - }), - telemetryCollectionEnabled: schema.boolean({ defaultValue: true }) + }) }) }; @@ -63,8 +62,7 @@ export function mergeConfigs( 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, - 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern, - 'xpack.apm.telemetryCollectionEnabled': apmConfig.telemetryCollectionEnabled + 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern }; } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts new file mode 100644 index 0000000000000..c45c74a791aee --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectAttributes } from '../../../../../../../src/core/server'; +import { createApmTelementry, storeApmServicesTelemetry } from '../index'; +import { + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID +} from '../../../../common/apm_saved_object_constants'; + +describe('apm_telemetry', () => { + describe('createApmTelementry', () => { + it('should create a ApmTelemetry object with boolean flag and frequency map of the given list of AgentNames', () => { + const apmTelemetry = createApmTelementry([ + 'go', + 'nodejs', + 'go', + 'js-base' + ]); + expect(apmTelemetry.has_any_services).toBe(true); + expect(apmTelemetry.services_per_agent).toMatchObject({ + go: 2, + nodejs: 1, + 'js-base': 1 + }); + }); + it('should ignore undefined or unknown AgentName values', () => { + const apmTelemetry = createApmTelementry([ + 'go', + 'nodejs', + 'go', + 'js-base', + 'example-platform' as any, + undefined as any + ]); + expect(apmTelemetry.services_per_agent).toMatchObject({ + go: 2, + nodejs: 1, + 'js-base': 1 + }); + }); + }); + + describe('storeApmServicesTelemetry', () => { + let apmTelemetry: SavedObjectAttributes; + let savedObjectsClient: any; + + beforeEach(() => { + apmTelemetry = { + has_any_services: true, + services_per_agent: { + go: 2, + nodejs: 1, + 'js-base': 1 + } + }; + savedObjectsClient = { create: jest.fn() }; + }); + + it('should call savedObjectsClient create with the given ApmTelemetry object', () => { + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); + expect(savedObjectsClient.create.mock.calls[0][1]).toBe(apmTelemetry); + }); + + it('should call savedObjectsClient create with the apm-telemetry document type and ID', () => { + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); + expect(savedObjectsClient.create.mock.calls[0][0]).toBe( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE + ); + expect(savedObjectsClient.create.mock.calls[0][2].id).toBe( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + ); + }); + + it('should call savedObjectsClient create with overwrite: true', () => { + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); + expect(savedObjectsClient.create.mock.calls[0][2].overwrite).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts deleted file mode 100644 index 729ccb73d73f3..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { merge } from 'lodash'; -import { Logger, CallAPIOptions } from 'kibana/server'; -import { IndicesStatsParams, Client } from 'elasticsearch'; -import { - ESSearchRequest, - ESSearchResponse -} from '../../../../typings/elasticsearch'; -import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; -import { tasks } from './tasks'; -import { APMDataTelemetry } from '../types'; - -type TelemetryTaskExecutor = (params: { - indices: ApmIndicesConfig; - search( - params: TSearchRequest - ): Promise>; - indicesStats( - params: IndicesStatsParams, - options?: CallAPIOptions - ): ReturnType; - transportRequest: (params: { - path: string; - method: 'get'; - }) => Promise; -}) => Promise; - -export interface TelemetryTask { - name: string; - executor: TelemetryTaskExecutor; -} - -export type CollectTelemetryParams = Parameters[0] & { - logger: Logger; -}; - -export function collectDataTelemetry({ - search, - indices, - logger, - indicesStats, - transportRequest -}: CollectTelemetryParams) { - return tasks.reduce((prev, task) => { - return prev.then(async data => { - logger.debug(`Executing APM telemetry task ${task.name}`); - try { - const time = process.hrtime(); - const next = await task.executor({ - search, - indices, - indicesStats, - transportRequest - }); - const took = process.hrtime(time); - - return merge({}, data, next, { - tasks: { - [task.name]: { - took: { - ms: Math.round(took[0] * 1000 + took[1] / 1e6) - } - } - } - }); - } catch (err) { - logger.warn(`Failed executing APM telemetry task ${task.name}`); - logger.warn(err); - return data; - } - }); - }, Promise.resolve({} as APMDataTelemetry)); -} diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts deleted file mode 100644 index 415076b6ae116..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ /dev/null @@ -1,725 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { flatten, merge, sortBy, sum } from 'lodash'; -import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; -import { AGENT_NAMES } from '../../../../common/agent_name'; -import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; -import { - PROCESSOR_EVENT, - SERVICE_NAME, - AGENT_NAME, - AGENT_VERSION, - ERROR_GROUP_ID, - TRANSACTION_NAME, - PARENT_ID, - SERVICE_FRAMEWORK_NAME, - SERVICE_FRAMEWORK_VERSION, - SERVICE_LANGUAGE_NAME, - SERVICE_LANGUAGE_VERSION, - SERVICE_RUNTIME_NAME, - SERVICE_RUNTIME_VERSION, - USER_AGENT_ORIGINAL -} from '../../../../common/elasticsearch_fieldnames'; -import { Span } from '../../../../typings/es_schemas/ui/span'; -import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; -import { TelemetryTask } from '.'; -import { APMTelemetry } from '../types'; - -const TIME_RANGES = ['1d', 'all'] as const; -type TimeRange = typeof TIME_RANGES[number]; - -export const tasks: TelemetryTask[] = [ - { - name: 'processor_events', - executor: async ({ indices, search }) => { - const indicesByProcessorEvent = { - error: indices['apm_oss.errorIndices'], - metric: indices['apm_oss.metricsIndices'], - span: indices['apm_oss.spanIndices'], - transaction: indices['apm_oss.transactionIndices'], - onboarding: indices['apm_oss.onboardingIndices'], - sourcemap: indices['apm_oss.sourcemapIndices'] - }; - - type ProcessorEvent = keyof typeof indicesByProcessorEvent; - - const jobs: Array<{ - processorEvent: ProcessorEvent; - timeRange: TimeRange; - }> = flatten( - (Object.keys( - indicesByProcessorEvent - ) as ProcessorEvent[]).map(processorEvent => - TIME_RANGES.map(timeRange => ({ processorEvent, timeRange })) - ) - ); - - const allData = await jobs.reduce((prevJob, current) => { - return prevJob.then(async data => { - const { processorEvent, timeRange } = current; - - const response = await search({ - index: indicesByProcessorEvent[processorEvent], - body: { - size: 1, - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: processorEvent } }, - ...(timeRange !== 'all' - ? [ - { - range: { - '@timestamp': { - gte: `now-${timeRange}` - } - } - } - ] - : []) - ] - } - }, - sort: { - '@timestamp': 'asc' - }, - _source: ['@timestamp'], - track_total_hits: true - } - }); - - const event = response.hits.hits[0]?._source as { - '@timestamp': number; - }; - - return merge({}, data, { - counts: { - [processorEvent]: { - [timeRange]: response.hits.total.value - } - }, - ...(timeRange === 'all' && event - ? { - retainment: { - [processorEvent]: { - ms: - new Date().getTime() - - new Date(event['@timestamp']).getTime() - } - } - } - : {}) - }); - }); - }, Promise.resolve({} as Record> }>)); - - return allData; - } - }, - { - name: 'agent_configuration', - executor: async ({ indices, search }) => { - const agentConfigurationCount = ( - await search({ - index: indices.apmAgentConfigurationIndex, - body: { - size: 0, - track_total_hits: true - } - }) - ).hits.total.value; - - return { - counts: { - agent_configuration: { - all: agentConfigurationCount - } - } - }; - } - }, - { - name: 'services', - executor: async ({ indices, search }) => { - const servicesPerAgent = await AGENT_NAMES.reduce( - (prevJob, agentName) => { - return prevJob.then(async data => { - const response = await search({ - index: [ - indices['apm_oss.errorIndices'], - indices['apm_oss.spanIndices'], - indices['apm_oss.metricsIndices'], - indices['apm_oss.transactionIndices'] - ], - body: { - size: 0, - query: { - bool: { - filter: [ - { - term: { - [AGENT_NAME]: agentName - } - }, - { - range: { - '@timestamp': { - gte: 'now-1d' - } - } - } - ] - } - }, - aggs: { - services: { - cardinality: { - field: SERVICE_NAME - } - } - } - } - }); - - return { - ...data, - [agentName]: response.aggregations?.services.value || 0 - }; - }); - }, - Promise.resolve({} as Record) - ); - - return { - has_any_services: sum(Object.values(servicesPerAgent)) > 0, - services_per_agent: servicesPerAgent - }; - } - }, - { - name: 'versions', - executor: async ({ search, indices }) => { - const response = await search({ - index: [ - indices['apm_oss.transactionIndices'], - indices['apm_oss.spanIndices'], - indices['apm_oss.errorIndices'] - ], - terminateAfter: 1, - body: { - query: { - exists: { - field: 'observer.version' - } - }, - size: 1, - sort: { - '@timestamp': 'desc' - } - } - }); - - const hit = response.hits.hits[0]?._source as Pick< - Transaction | Span | APMError, - 'observer' - >; - - if (!hit || !hit.observer?.version) { - return {}; - } - - const [major, minor, patch] = hit.observer.version - .split('.') - .map(part => Number(part)); - - return { - versions: { - apm_server: { - major, - minor, - patch - } - } - }; - } - }, - { - name: 'groupings', - executor: async ({ search, indices }) => { - const range1d = { range: { '@timestamp': { gte: 'now-1d' } } }; - const errorGroupsCount = ( - await search({ - index: indices['apm_oss.errorIndices'], - body: { - size: 0, - query: { - bool: { - filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d] - } - }, - aggs: { - top_service: { - terms: { - field: SERVICE_NAME, - order: { - error_groups: 'desc' - }, - size: 1 - }, - aggs: { - error_groups: { - cardinality: { - field: ERROR_GROUP_ID - } - } - } - } - } - } - }) - ).aggregations?.top_service.buckets[0]?.error_groups.value; - - const transactionGroupsCount = ( - await search({ - index: indices['apm_oss.transactionIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - range1d - ] - } - }, - aggs: { - top_service: { - terms: { - field: SERVICE_NAME, - order: { - transaction_groups: 'desc' - }, - size: 1 - }, - aggs: { - transaction_groups: { - cardinality: { - field: TRANSACTION_NAME - } - } - } - } - } - } - }) - ).aggregations?.top_service.buckets[0]?.transaction_groups.value; - - const tracesPerDayCount = ( - await search({ - index: indices['apm_oss.transactionIndices'], - body: { - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - range1d - ], - must_not: { - exists: { field: PARENT_ID } - } - } - }, - track_total_hits: true, - size: 0 - } - }) - ).hits.total.value; - - const servicesCount = ( - await search({ - index: [ - indices['apm_oss.transactionIndices'], - indices['apm_oss.errorIndices'], - indices['apm_oss.metricsIndices'] - ], - body: { - size: 0, - query: { - bool: { - filter: [range1d] - } - }, - aggs: { - service_name: { - cardinality: { - field: SERVICE_NAME - } - } - } - } - }) - ).aggregations?.service_name.value; - - return { - counts: { - max_error_groups_per_service: { - '1d': errorGroupsCount || 0 - }, - max_transaction_groups_per_service: { - '1d': transactionGroupsCount || 0 - }, - traces: { - '1d': tracesPerDayCount || 0 - }, - services: { - '1d': servicesCount || 0 - } - } - }; - } - }, - { - name: 'integrations', - executor: async ({ transportRequest }) => { - const apmJobs = ['*-high_mean_response_time']; - - const response = (await transportRequest({ - method: 'get', - path: `/_ml/anomaly_detectors/${apmJobs.join(',')}` - })) as { data?: { count: number } }; - - return { - integrations: { - ml: { - all_jobs_count: response.data?.count ?? 0 - } - } - }; - } - }, - { - name: 'agents', - executor: async ({ search, indices }) => { - const size = 3; - - const agentData = await AGENT_NAMES.reduce(async (prevJob, agentName) => { - const data = await prevJob; - - const response = await search({ - index: [ - indices['apm_oss.errorIndices'], - indices['apm_oss.metricsIndices'], - indices['apm_oss.transactionIndices'] - ], - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [AGENT_NAME]: agentName } }, - { range: { '@timestamp': { gte: 'now-1d' } } } - ] - } - }, - sort: { - '@timestamp': 'desc' - }, - aggs: { - [AGENT_VERSION]: { - terms: { - field: AGENT_VERSION, - size - } - }, - [SERVICE_FRAMEWORK_NAME]: { - terms: { - field: SERVICE_FRAMEWORK_NAME, - size - }, - aggs: { - [SERVICE_FRAMEWORK_VERSION]: { - terms: { - field: SERVICE_FRAMEWORK_VERSION, - size - } - } - } - }, - [SERVICE_FRAMEWORK_VERSION]: { - terms: { - field: SERVICE_FRAMEWORK_VERSION, - size - } - }, - [SERVICE_LANGUAGE_NAME]: { - terms: { - field: SERVICE_LANGUAGE_NAME, - size - }, - aggs: { - [SERVICE_LANGUAGE_VERSION]: { - terms: { - field: SERVICE_LANGUAGE_VERSION, - size - } - } - } - }, - [SERVICE_LANGUAGE_VERSION]: { - terms: { - field: SERVICE_LANGUAGE_VERSION, - size - } - }, - [SERVICE_RUNTIME_NAME]: { - terms: { - field: SERVICE_RUNTIME_NAME, - size - }, - aggs: { - [SERVICE_RUNTIME_VERSION]: { - terms: { - field: SERVICE_RUNTIME_VERSION, - size - } - } - } - }, - [SERVICE_RUNTIME_VERSION]: { - terms: { - field: SERVICE_RUNTIME_VERSION, - size - } - } - } - } - }); - - const { aggregations } = response; - - if (!aggregations) { - return data; - } - - const toComposite = ( - outerKey: string | number, - innerKey: string | number - ) => `${outerKey}/${innerKey}`; - - return { - ...data, - [agentName]: { - agent: { - version: aggregations[AGENT_VERSION].buckets.map( - bucket => bucket.key as string - ) - }, - service: { - framework: { - name: aggregations[SERVICE_FRAMEWORK_NAME].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - version: aggregations[SERVICE_FRAMEWORK_VERSION].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - composite: sortBy( - flatten( - aggregations[SERVICE_FRAMEWORK_NAME].buckets.map(bucket => - bucket[SERVICE_FRAMEWORK_VERSION].buckets.map( - versionBucket => ({ - doc_count: versionBucket.doc_count, - name: toComposite(bucket.key, versionBucket.key) - }) - ) - ) - ), - 'doc_count' - ) - .reverse() - .slice(0, size) - .map(composite => composite.name) - }, - language: { - name: aggregations[SERVICE_LANGUAGE_NAME].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - version: aggregations[SERVICE_LANGUAGE_VERSION].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - composite: sortBy( - flatten( - aggregations[SERVICE_LANGUAGE_NAME].buckets.map(bucket => - bucket[SERVICE_LANGUAGE_VERSION].buckets.map( - versionBucket => ({ - doc_count: versionBucket.doc_count, - name: toComposite(bucket.key, versionBucket.key) - }) - ) - ) - ), - 'doc_count' - ) - .reverse() - .slice(0, size) - .map(composite => composite.name) - }, - runtime: { - name: aggregations[SERVICE_RUNTIME_NAME].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - version: aggregations[SERVICE_RUNTIME_VERSION].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - composite: sortBy( - flatten( - aggregations[SERVICE_RUNTIME_NAME].buckets.map(bucket => - bucket[SERVICE_RUNTIME_VERSION].buckets.map( - versionBucket => ({ - doc_count: versionBucket.doc_count, - name: toComposite(bucket.key, versionBucket.key) - }) - ) - ) - ), - 'doc_count' - ) - .reverse() - .slice(0, size) - .map(composite => composite.name) - } - } - } - }; - }, Promise.resolve({} as APMTelemetry['agents'])); - - return { - agents: agentData - }; - } - }, - { - name: 'indices_stats', - executor: async ({ indicesStats, indices }) => { - const response = await indicesStats({ - index: [ - indices.apmAgentConfigurationIndex, - indices['apm_oss.errorIndices'], - indices['apm_oss.metricsIndices'], - indices['apm_oss.onboardingIndices'], - indices['apm_oss.sourcemapIndices'], - indices['apm_oss.spanIndices'], - indices['apm_oss.transactionIndices'] - ] - }); - - return { - indices: { - shards: { - total: response._shards.total - }, - all: { - total: { - docs: { - count: response._all.total.docs.count - }, - store: { - size_in_bytes: response._all.total.store.size_in_bytes - } - } - } - } - }; - } - }, - { - name: 'cardinality', - executor: async ({ search }) => { - const allAgentsCardinalityResponse = await search({ - body: { - size: 0, - query: { - bool: { - filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }] - } - }, - aggs: { - [TRANSACTION_NAME]: { - cardinality: { - field: TRANSACTION_NAME - } - }, - [USER_AGENT_ORIGINAL]: { - cardinality: { - field: USER_AGENT_ORIGINAL - } - } - } - } - }); - - const rumAgentCardinalityResponse = await search({ - body: { - size: 0, - query: { - bool: { - filter: [ - { range: { '@timestamp': { gte: 'now-1d' } } }, - { terms: { [AGENT_NAME]: ['rum-js', 'js-base'] } } - ] - } - }, - aggs: { - [TRANSACTION_NAME]: { - cardinality: { - field: TRANSACTION_NAME - } - }, - [USER_AGENT_ORIGINAL]: { - cardinality: { - field: USER_AGENT_ORIGINAL - } - } - } - } - }); - - return { - cardinality: { - transaction: { - name: { - all_agents: { - '1d': - allAgentsCardinalityResponse.aggregations?.[TRANSACTION_NAME] - .value - }, - rum: { - '1d': - rumAgentCardinalityResponse.aggregations?.[TRANSACTION_NAME] - .value - } - } - }, - user_agent: { - original: { - all_agents: { - '1d': - allAgentsCardinalityResponse.aggregations?.[ - USER_AGENT_ORIGINAL - ].value - }, - rum: { - '1d': - rumAgentCardinalityResponse.aggregations?.[ - USER_AGENT_ORIGINAL - ].value - } - } - } - } - }; - } - } -]; diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index c80057a2894dc..a2b0494730826 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -3,127 +3,60 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, Logger } from 'src/core/server'; -import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { - TaskManagerStartContract, - TaskManagerSetupContract -} from '../../../../task_manager/server'; -import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; + +import { countBy } from 'lodash'; +import { SavedObjectAttributes } from '../../../../../../src/core/server'; +import { isAgentName } from '../../../common/agent_name'; import { - APM_TELEMETRY_SAVED_OBJECT_ID, - APM_TELEMETRY_SAVED_OBJECT_TYPE + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID } from '../../../common/apm_saved_object_constants'; -import { - collectDataTelemetry, - CollectTelemetryParams -} from './collect_data_telemetry'; -import { APMConfig } from '../..'; -import { getInternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; - -const APM_TELEMETRY_TASK_NAME = 'apm-telemetry-task'; - -export async function createApmTelemetry({ - core, - config$, - usageCollector, - taskManager, - logger -}: { - core: CoreSetup; - config$: Observable; - usageCollector: UsageCollectionSetup; - taskManager: TaskManagerSetupContract; - logger: Logger; -}) { - const savedObjectsClient = await getInternalSavedObjectsClient(core); - - const collectAndStore = async () => { - const config = await config$.pipe(take(1)).toPromise(); - const esClient = core.elasticsearch.dataClient; - - const indices = await getApmIndices({ - config, - savedObjectsClient - }); - - const search = esClient.callAsInternalUser.bind( - esClient, - 'search' - ) as CollectTelemetryParams['search']; - - const indicesStats = esClient.callAsInternalUser.bind( - esClient, - 'indices.stats' - ) as CollectTelemetryParams['indicesStats']; - - const transportRequest = esClient.callAsInternalUser.bind( - esClient, - 'transport.request' - ) as CollectTelemetryParams['transportRequest']; - - const dataTelemetry = await collectDataTelemetry({ - search, - indices, - logger, - indicesStats, - transportRequest - }); - - await savedObjectsClient.create( - APM_TELEMETRY_SAVED_OBJECT_TYPE, - dataTelemetry, - { id: APM_TELEMETRY_SAVED_OBJECT_TYPE, overwrite: true } - ); +import { UsageCollectionSetup } from '../../../../../../src/plugins/usage_collection/server'; +import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; + +export function createApmTelementry( + agentNames: string[] = [] +): SavedObjectAttributes { + const validAgentNames = agentNames.filter(isAgentName); + return { + has_any_services: validAgentNames.length > 0, + services_per_agent: countBy(validAgentNames) }; +} - taskManager.registerTaskDefinitions({ - [APM_TELEMETRY_TASK_NAME]: { - title: 'Collect APM telemetry', - type: APM_TELEMETRY_TASK_NAME, - createTaskRunner: () => { - return { - run: async () => { - await collectAndStore(); - } - }; - } +export async function storeApmServicesTelemetry( + savedObjectsClient: InternalSavedObjectsClient, + apmTelemetry: SavedObjectAttributes +) { + return savedObjectsClient.create( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + apmTelemetry, + { + id: APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID, + overwrite: true } - }); + ); +} - const collector = usageCollector.makeUsageCollector({ +export function makeApmUsageCollector( + usageCollector: UsageCollectionSetup, + savedObjectsRepository: InternalSavedObjectsClient +) { + const apmUsageCollector = usageCollector.makeUsageCollector({ type: 'apm', fetch: async () => { - const data = ( - await savedObjectsClient.get( - APM_TELEMETRY_SAVED_OBJECT_TYPE, - APM_TELEMETRY_SAVED_OBJECT_ID - ) - ).attributes; - - return data; + try { + const apmTelemetrySavedObject = await savedObjectsRepository.get( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + ); + return apmTelemetrySavedObject.attributes; + } catch (err) { + return createApmTelementry(); + } }, isReady: () => true }); - usageCollector.registerCollector(collector); - - core.getStartServices().then(([coreStart, pluginsStart]) => { - const { taskManager: taskManagerStart } = pluginsStart as { - taskManager: TaskManagerStartContract; - }; - - taskManagerStart.ensureScheduled({ - id: APM_TELEMETRY_TASK_NAME, - taskType: APM_TELEMETRY_TASK_NAME, - schedule: { - interval: '720m' - }, - scope: ['apm'], - params: {}, - state: {} - }); - }); + usageCollector.registerCollector(apmUsageCollector); } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts deleted file mode 100644 index f68dc517a2227..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DeepPartial } from 'utility-types'; -import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; - -export interface TimeframeMap { - '1d': number; - all: number; -} - -export type TimeframeMap1d = Pick; -export type TimeframeMapAll = Pick; - -export type APMDataTelemetry = DeepPartial<{ - has_any_services: boolean; - services_per_agent: Record; - versions: { - apm_server: { - minor: number; - major: number; - patch: number; - }; - }; - counts: { - transaction: TimeframeMap; - span: TimeframeMap; - error: TimeframeMap; - metric: TimeframeMap; - sourcemap: TimeframeMap; - onboarding: TimeframeMap; - agent_configuration: TimeframeMapAll; - max_transaction_groups_per_service: TimeframeMap; - max_error_groups_per_service: TimeframeMap; - traces: TimeframeMap; - services: TimeframeMap; - }; - cardinality: { - user_agent: { - original: { - all_agents: TimeframeMap1d; - rum: TimeframeMap1d; - }; - }; - transaction: { - name: { - all_agents: TimeframeMap1d; - rum: TimeframeMap1d; - }; - }; - }; - retainment: Record< - 'span' | 'transaction' | 'error' | 'metric' | 'sourcemap' | 'onboarding', - { ms: number } - >; - integrations: { - ml: { - all_jobs_count: number; - }; - }; - agents: Record< - AgentName, - { - agent: { - version: string[]; - }; - service: { - framework: { - name: string[]; - version: string[]; - composite: string[]; - }; - language: { - name: string[]; - version: string[]; - composite: string[]; - }; - runtime: { - name: string[]; - version: string[]; - composite: string[]; - }; - }; - } - >; - indices: { - shards: { - total: number; - }; - all: { - total: { - docs: { - count: number; - }; - store: { - size_in_bytes: number; - }; - }; - }; - }; - tasks: Record< - | 'processor_events' - | 'agent_configuration' - | 'services' - | 'versions' - | 'groupings' - | 'integrations' - | 'agents' - | 'indices_stats' - | 'cardinality', - { took: { ms: number } } - >; -}>; - -export type APMTelemetry = APMDataTelemetry; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 8e8cf698a84cf..40a2a0e7216a0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -39,19 +39,6 @@ function getMockRequest() { _debug: false } }, - __LEGACY: { - server: { - plugins: { - elasticsearch: { - getCluster: jest.fn().mockReturnValue({ callWithInternalUser: {} }) - } - }, - savedObjects: { - SavedObjectsClient: jest.fn(), - getSavedObjectsRepository: jest.fn() - } - } - }, core: { elasticsearch: { dataClient: { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index a29b9399d8435..db14730f802a9 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -8,9 +8,9 @@ import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; import { once } from 'lodash'; -import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; -import { TaskManagerSetupContract } from '../../task_manager/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; +import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; @@ -21,7 +21,6 @@ import { tutorialProvider } from './tutorial'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; -import { createApmTelemetry } from './lib/apm_telemetry'; export interface LegacySetup { server: Server; @@ -48,10 +47,9 @@ export class APMPlugin implements Plugin { licensing: LicensingPluginSetup; cloud?: CloudSetup; usageCollection?: UsageCollectionSetup; - taskManager?: TaskManagerSetupContract; } ) { - const logger = this.initContext.logger.get(); + const logger = this.initContext.logger.get('apm'); const config$ = this.initContext.config.create(); const mergedConfig$ = combineLatest(plugins.apm_oss.config$, config$).pipe( map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) @@ -63,20 +61,6 @@ export class APMPlugin implements Plugin { const currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); - if ( - plugins.taskManager && - plugins.usageCollection && - currentConfig['xpack.apm.telemetryCollectionEnabled'] - ) { - createApmTelemetry({ - core, - config$: mergedConfig$, - usageCollector: plugins.usageCollection, - taskManager: plugins.taskManager, - logger - }); - } - // create agent configuration index without blocking setup lifecycle createApmAgentConfigurationIndex({ esClient: core.elasticsearch.dataClient, @@ -105,6 +89,18 @@ export class APMPlugin implements Plugin { }) ); + const usageCollection = plugins.usageCollection; + if (usageCollection) { + getInternalSavedObjectsClient(core) + .then(savedObjectsClient => { + makeApmUsageCollector(usageCollection, savedObjectsClient); + }) + .catch(error => { + logger.error('Unable to initialize use collection'); + logger.error(error.message); + }); + } + return { config$: mergedConfig$, registerLegacyAPI: once((__LEGACY: LegacySetup) => { @@ -119,7 +115,6 @@ export class APMPlugin implements Plugin { }; } - public async start() {} - + public start() {} public stop() {} } diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index 312dae1d1f9d2..e639bb5101e2f 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -36,7 +36,6 @@ const getCoreMock = () => { put, createRouter, context: { - measure: () => undefined, config$: new BehaviorSubject({} as APMConfig), logger: ({ error: jest.fn() diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 1c6561ee24c93..2d4fae9d2707a 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,6 +5,11 @@ */ import * as t from 'io-ts'; +import { AgentName } from '../../typings/es_schemas/ui/fields/agent'; +import { + createApmTelementry, + storeApmServicesTelemetry +} from '../lib/apm_telemetry'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; @@ -13,6 +18,7 @@ import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadat import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; +import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; export const servicesRoute = createRoute(core => ({ path: '/api/apm/services', @@ -23,6 +29,16 @@ export const servicesRoute = createRoute(core => ({ const setup = await setupRequest(context, request); const services = await getServices(setup); + // Store telemetry data derived from services + const agentNames = services.items.map( + ({ agentName }) => agentName as AgentName + ); + const apmTelemetry = createApmTelementry(agentNames); + const savedObjectsClient = await getInternalSavedObjectsClient(core); + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry).catch(error => { + context.logger.error(error.message); + }); + return services; } })); diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 8a8d256cf4273..6d3620f11a87b 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -126,16 +126,6 @@ export interface AggregationOptionsByType { combine_script: Script; reduce_script: Script; }; - date_range: { - field: string; - format?: string; - ranges: Array< - | { from: string | number } - | { to: string | number } - | { from: string | number; to: string | number } - >; - keyed?: boolean; - }; } type AggregationType = keyof AggregationOptionsByType; @@ -146,15 +136,6 @@ type AggregationOptionsMap = Unionize< } > & { aggs?: AggregationInputMap }; -interface DateRangeBucket { - key: string; - to?: number; - from?: number; - to_as_string?: string; - from_as_string?: string; - doc_count: number; -} - export interface AggregationInputMap { [key: string]: AggregationOptionsMap; } @@ -295,11 +276,6 @@ interface AggregationResponsePart< scripted_metric: { value: unknown; }; - date_range: { - buckets: TAggregationOptionsMap extends { date_range: { keyed: true } } - ? Record - : { buckets: DateRangeBucket[] }; - }; } // Type for debugging purposes. If you see an error in AggregationResponseMap @@ -309,7 +285,7 @@ interface AggregationResponsePart< // type MissingAggregationResponseTypes = Exclude< // AggregationType, -// keyof AggregationResponsePart<{}, unknown> +// keyof AggregationResponsePart<{}> // >; export type AggregationResponseMap< diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts index 8e49d02beb908..daf65e44980b6 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts @@ -15,7 +15,6 @@ import { Service } from './fields/service'; import { IStackframe } from './fields/stackframe'; import { Url } from './fields/url'; import { User } from './fields/user'; -import { Observer } from './fields/observer'; interface Processor { name: 'error'; @@ -62,5 +61,4 @@ export interface ErrorRaw extends APMBaseDoc { service: Service; url?: Url; user?: User; - observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts deleted file mode 100644 index 42843130ec47f..0000000000000 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export interface Observer { - version: string; - version_major: number; -} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts index 4d5d2c5c4a12e..dbd9e7ede4256 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts @@ -6,7 +6,6 @@ import { APMBaseDoc } from './apm_base_doc'; import { IStackframe } from './fields/stackframe'; -import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -51,5 +50,4 @@ export interface SpanRaw extends APMBaseDoc { transaction?: { id: string; }; - observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index b8ebb4cf8da51..3673f1f13c403 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -15,7 +15,6 @@ import { Service } from './fields/service'; import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; -import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -62,5 +61,4 @@ export interface TransactionRaw extends APMBaseDoc { url?: Url; user?: User; user_agent?: UserAgent; - observer?: Observer; } From 3e26777965a2efa56f7a2040619f016f34af5d0e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 24 Mar 2020 07:27:15 +0100 Subject: [PATCH 10/64] Migrate doc view part of discover (#58094) --- .i18nrc.json | 1 + src/core/MIGRATION.md | 6 +- .../kibana/public/discover/build_services.ts | 14 ++- .../kibana/public/discover/kibana_services.ts | 1 - .../np_ready/angular/directives/field_name.js | 2 +- .../angular/{doc_viewer.ts => doc_viewer.tsx} | 8 +- .../discover/np_ready/components/_index.scss | 1 - .../np_ready/components/doc/doc.test.tsx | 8 +- .../discover/np_ready/components/doc/doc.tsx | 5 +- .../components/doc/use_es_doc_search.ts | 3 +- .../kibana/public/discover/plugin.ts | 46 ++------ .../new_platform/new_platform.karma_mock.js | 11 ++ .../ui/public/new_platform/new_platform.ts | 3 + src/plugins/discover/kibana.json | 6 + .../discover/public/components/_index.scss | 1 + .../__snapshots__/doc_viewer.test.tsx.snap | 0 .../doc_viewer_render_tab.test.tsx.snap | 0 .../components/doc_viewer/_doc_viewer.scss | 0 .../public}/components/doc_viewer/_index.scss | 0 .../components/doc_viewer/doc_viewer.test.tsx | 26 ++--- .../components/doc_viewer/doc_viewer.tsx | 4 +- .../doc_viewer/doc_viewer_render_error.tsx | 2 +- .../doc_viewer/doc_viewer_render_tab.test.tsx | 0 .../doc_viewer/doc_viewer_render_tab.tsx | 0 .../components/doc_viewer/doc_viewer_tab.tsx | 0 .../__snapshots__/field_name.test.tsx.snap | 0 .../field_name/field_name.test.tsx | 0 .../components}/field_name/field_name.tsx | 4 +- .../components}/field_name/field_type_name.ts | 24 ++-- .../json_code_block.test.tsx.snap | 0 .../json_code_block/json_code_block.test.tsx | 2 +- .../json_code_block/json_code_block.tsx | 2 +- .../public}/components/table/table.test.tsx | 5 +- .../public}/components/table/table.tsx | 0 .../components/table/table_helper.test.ts | 0 .../public}/components/table/table_helper.tsx | 0 .../public}/components/table/table_row.tsx | 2 +- .../table/table_row_btn_collapse.tsx | 2 +- .../table/table_row_btn_filter_add.tsx | 6 +- .../table/table_row_btn_filter_exists.tsx | 15 +-- .../table/table_row_btn_filter_remove.tsx | 6 +- .../table/table_row_btn_toggle_column.tsx | 20 ++-- .../table/table_row_icon_no_mapping.tsx | 11 +- .../table/table_row_icon_underscore.tsx | 4 +- .../public}/doc_views/doc_views_helpers.tsx | 0 .../public}/doc_views/doc_views_registry.ts | 12 +- .../public}/doc_views/doc_views_types.ts | 7 +- src/plugins/discover/public/helpers/index.ts | 20 ++++ .../public/helpers/shorten_dotted_string.ts | 26 +++++ src/plugins/discover/public/index.scss | 1 + src/plugins/discover/public/index.ts | 13 +++ src/plugins/discover/public/mocks.ts | 47 ++++++++ src/plugins/discover/public/plugin.ts | 110 ++++++++++++++++++ .../public/saved_searches/_saved_search.ts | 8 +- src/plugins/discover/public/services.ts | 25 ++++ test/plugin_functional/config.js | 1 + .../plugins/doc_views_plugin/kibana.json | 8 ++ .../plugins/doc_views_plugin/package.json | 17 +++ .../plugins/doc_views_plugin/public/index.ts | 22 ++++ .../doc_views_plugin/public/plugin.tsx | 60 ++++++++++ .../plugins/doc_views_plugin/tsconfig.json | 14 +++ .../test_suites/doc_views/doc_views.ts | 57 +++++++++ .../test_suites/doc_views/index.ts | 31 +++++ .../translations/translations/ja-JP.json | 22 ++-- .../translations/translations/zh-CN.json | 22 ++-- 65 files changed, 606 insertions(+), 168 deletions(-) rename src/legacy/core_plugins/kibana/public/discover/np_ready/angular/{doc_viewer.ts => doc_viewer.tsx} (87%) create mode 100644 src/plugins/discover/kibana.json create mode 100644 src/plugins/discover/public/components/_index.scss rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/_doc_viewer.scss (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/_index.scss (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer.test.tsx (81%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer.tsx (95%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_render_error.tsx (94%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_render_tab.test.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_render_tab.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_tab.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/__snapshots__/field_name.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/field_name.test.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/field_name.tsx (94%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/field_type_name.ts (66%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/json_code_block/json_code_block.test.tsx (95%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/json_code_block/json_code_block.tsx (93%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table.test.tsx (98%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_helper.test.ts (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_helper.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row.tsx (98%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_collapse.tsx (94%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_filter_add.tsx (87%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_filter_exists.tsx (80%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_filter_remove.tsx (87%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_toggle_column.tsx (79%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_icon_no_mapping.tsx (86%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_icon_underscore.tsx (89%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/doc_views/doc_views_helpers.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/doc_views/doc_views_registry.ts (82%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/doc_views/doc_views_types.ts (90%) create mode 100644 src/plugins/discover/public/helpers/index.ts create mode 100644 src/plugins/discover/public/helpers/shorten_dotted_string.ts create mode 100644 src/plugins/discover/public/index.scss create mode 100644 src/plugins/discover/public/mocks.ts create mode 100644 src/plugins/discover/public/plugin.ts create mode 100644 src/plugins/discover/public/services.ts create mode 100644 test/plugin_functional/plugins/doc_views_plugin/kibana.json create mode 100644 test/plugin_functional/plugins/doc_views_plugin/package.json create mode 100644 test/plugin_functional/plugins/doc_views_plugin/public/index.ts create mode 100644 test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx create mode 100644 test/plugin_functional/plugins/doc_views_plugin/tsconfig.json create mode 100644 test/plugin_functional/test_suites/doc_views/doc_views.ts create mode 100644 test/plugin_functional/test_suites/doc_views/index.ts diff --git a/.i18nrc.json b/.i18nrc.json index 36b28a0f5bd34..78c4be6f4a356 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -3,6 +3,7 @@ "common.ui": "src/legacy/ui", "console": "src/plugins/console", "core": "src/core", + "discover": "src/plugins/discover", "dashboard": "src/plugins/dashboard", "data": "src/plugins/data", "embeddableApi": "src/plugins/embeddable", diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 1ca9b63a51d18..0d5d300ec3b79 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -1233,11 +1233,11 @@ This table shows where these uiExports have moved to in the New Platform. In mos | `chromeNavControls` | [`core.chrome.navControls.register{Left,Right}`](/docs/development/core/public/kibana-plugin-public.chromenavcontrols.md) | | | `contextMenuActions` | | Should be an API on the devTools plugin. | | `devTools` | | | -| `docViews` | | | +| `docViews` | [`plugins.discover.docViews.addDocView`](./src/plugins/discover/public/doc_views) | Should be an API on the discover plugin. | | `embeddableActions` | | Should be an API on the embeddables plugin. | | `embeddableFactories` | | Should be an API on the embeddables plugin. | -| `fieldFormatEditors` | | | -| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | +| `fieldFormatEditors` | | | +| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | | `hacks` | n/a | Just run the code in your plugin's `start` method. | | `home` | [`plugins.home.featureCatalogue.register`](./src/plugins/home/public/feature_catalogue) | Must add `home` as a dependency in your kibana.json. | | `indexManagement` | | Should be an API on the indexManagement plugin. | diff --git a/src/legacy/core_plugins/kibana/public/discover/build_services.ts b/src/legacy/core_plugins/kibana/public/discover/build_services.ts index 282eef0c983eb..f881eb96e4e81 100644 --- a/src/legacy/core_plugins/kibana/public/discover/build_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/build_services.ts @@ -35,10 +35,13 @@ import { import { DiscoverStartPlugins } from './plugin'; import { SharePluginStart } from '../../../../../plugins/share/public'; -import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; import { ChartsPluginStart } from '../../../../../plugins/charts/public'; import { VisualizationsStart } from '../../../visualizations/public'; -import { createSavedSearchesLoader, SavedSearch } from '../../../../../plugins/discover/public'; +import { + createSavedSearchesLoader, + DocViewerComponent, + SavedSearch, +} from '../../../../../plugins/discover/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -47,7 +50,7 @@ export interface DiscoverServices { core: CoreStart; data: DataPublicPluginStart; docLinks: DocLinksStart; - docViewsRegistry: DocViewsRegistry; + DocViewer: DocViewerComponent; history: History; theme: ChartsPluginStart['theme']; filterManager: FilterManager; @@ -64,8 +67,7 @@ export interface DiscoverServices { } export async function buildServices( core: CoreStart, - plugins: DiscoverStartPlugins, - docViewsRegistry: DocViewsRegistry + plugins: DiscoverStartPlugins ): Promise { const services = { savedObjectsClient: core.savedObjects.client, @@ -81,7 +83,7 @@ export async function buildServices( core, data: plugins.data, docLinks: core.docLinks, - docViewsRegistry, + DocViewer: plugins.discover.docViews.DocViewer, history: createHashHistory(), theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index d369eb9679de6..7a3a6949baa94 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -75,7 +75,6 @@ export { EsQuerySortValue, SortDirection, } from '../../../../../plugins/data/public'; -export { ElasticSearchHit } from './np_ready/doc_views/doc_views_types'; export { getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; // @ts-ignore export { buildPointSeriesData } from 'ui/agg_response/point_series/point_series'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js index b020113381992..47e50f3cc3d4b 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { FieldName } from './field_name/field_name'; +import { FieldName } from '../../../../../../../../plugins/discover/public'; import { getServices, wrapInI18nContext } from '../../../kibana_services'; export function FieldNameDirectiveProvider(reactDirective) { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts rename to src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx index 6ba47b839563b..90e061ac1aa05 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx @@ -17,11 +17,15 @@ * under the License. */ -import { DocViewer } from '../components/doc_viewer/doc_viewer'; +import React from 'react'; +import { getServices } from '../../kibana_services'; export function createDocViewerDirective(reactDirective: any) { return reactDirective( - DocViewer, + (props: any) => { + const { DocViewer } = getServices(); + return ; + }, [ 'hit', ['indexPattern', { watchDepth: 'reference' }], diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss index 0491430e5fddd..7161560f8fda4 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss @@ -1,3 +1,2 @@ @import 'fetch_error/index'; @import 'field_chooser/index'; -@import 'doc_viewer/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx index 2278b243ecc14..1d19dc112d193 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx @@ -24,19 +24,13 @@ import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; -jest.mock('../doc_viewer/doc_viewer', () => ({ - DocViewer: () => null, -})); - jest.mock('../../../kibana_services', () => { return { getServices: () => ({ metadata: { branch: 'test', }, - getDocViewsSorted: () => { - return []; - }, + DocViewer: () => null, }), }; }); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx index 819eb9df592bd..28a17dbdb67b7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx @@ -20,9 +20,9 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent } from '@elastic/eui'; import { IndexPatternsContract } from 'src/plugins/data/public'; -import { DocViewer } from '../doc_viewer/doc_viewer'; import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search'; -import { ElasticSearchHit, getServices } from '../../../kibana_services'; +import { getServices } from '../../../kibana_services'; +import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; export interface ElasticSearchResult { hits: { @@ -61,6 +61,7 @@ export interface DocProps { } export function Doc(props: DocProps) { + const { DocViewer } = getServices(); const [reqState, hit, indexPattern] = useEsDocSearch(props); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts index 6cffc2cc533b0..2cd264578a596 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts @@ -17,8 +17,9 @@ * under the License. */ import { useEffect, useState } from 'react'; -import { ElasticSearchHit, IndexPattern } from '../../../kibana_services'; +import { IndexPattern } from '../../../kibana_services'; import { DocProps } from './doc'; +import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; export enum ElasticRequestState { Loading, diff --git a/src/legacy/core_plugins/kibana/public/discover/plugin.ts b/src/legacy/core_plugins/kibana/public/discover/plugin.ts index ba671a64592a5..d3cdeb49fba71 100644 --- a/src/legacy/core_plugins/kibana/public/discover/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/discover/plugin.ts @@ -19,7 +19,6 @@ import { BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'kibana/public'; import angular, { auto } from 'angular'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; @@ -41,10 +40,7 @@ import { KibanaLegacySetup, AngularRenderedAppUpdater, } from '../../../../../plugins/kibana_legacy/public'; -import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; -import { DocViewInput, DocViewInputFn } from './np_ready/doc_views/doc_views_types'; -import { DocViewTable } from './np_ready/components/table/table'; -import { JsonCodeBlock } from './np_ready/components/json_code_block/json_code_block'; +import { DiscoverSetup, DiscoverStart } from '../../../../../plugins/discover/public'; import { HomePublicPluginSetup } from '../../../../../plugins/home/public'; import { VisualizationsStart, @@ -52,15 +48,6 @@ import { } from '../../../visualizations/public/np_ready/public'; import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; -/** - * These are the interfaces with your public contracts. You should export these - * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. - * @public - */ -export interface DiscoverSetup { - addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; -} -export type DiscoverStart = void; export interface DiscoverSetupPlugins { uiActions: UiActionsSetup; embeddable: EmbeddableSetup; @@ -68,6 +55,7 @@ export interface DiscoverSetupPlugins { home: HomePublicPluginSetup; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; + discover: DiscoverSetup; } export interface DiscoverStartPlugins { uiActions: UiActionsStart; @@ -78,6 +66,7 @@ export interface DiscoverStartPlugins { share: SharePluginStart; inspector: any; visualizations: VisualizationsStart; + discover: DiscoverStart; } const innerAngularName = 'app/discover'; const embeddableAngularName = 'app/discoverEmbeddable'; @@ -87,10 +76,9 @@ const embeddableAngularName = 'app/discoverEmbeddable'; * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular * Discover provides embeddables, those contain a slimmer Angular */ -export class DiscoverPlugin implements Plugin { +export class DiscoverPlugin implements Plugin { private servicesInitialized: boolean = false; private innerAngularInitialized: boolean = false; - private docViewsRegistry: DocViewsRegistry | null = null; private embeddableInjector: auto.IInjectorService | null = null; private getEmbeddableInjector: (() => Promise) | null = null; private appStateUpdater = new BehaviorSubject(() => ({})); @@ -103,7 +91,7 @@ export class DiscoverPlugin implements Plugin { public initializeInnerAngular?: () => void; public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; - setup(core: CoreSetup, plugins: DiscoverSetupPlugins): DiscoverSetup { + setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/kibana'), defaultSubUrl: '#/discover', @@ -130,21 +118,7 @@ export class DiscoverPlugin implements Plugin { }; this.getEmbeddableInjector = this.getInjector.bind(this); - this.docViewsRegistry = new DocViewsRegistry(this.getEmbeddableInjector); - this.docViewsRegistry.addDocView({ - title: i18n.translate('kbn.discover.docViews.table.tableTitle', { - defaultMessage: 'Table', - }), - order: 10, - component: DocViewTable, - }); - this.docViewsRegistry.addDocView({ - title: i18n.translate('kbn.discover.docViews.json.jsonTitle', { - defaultMessage: 'JSON', - }), - order: 20, - component: JsonCodeBlock, - }); + plugins.discover.docViews.setAngularInjectorGetter(this.getEmbeddableInjector); plugins.kibanaLegacy.registerLegacyApp({ id: 'discover', title: 'Discover', @@ -172,14 +146,10 @@ export class DiscoverPlugin implements Plugin { }, }); registerFeature(plugins.home); - this.registerEmbeddable(core, plugins); - return { - addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), - }; } - start(core: CoreStart, plugins: DiscoverStartPlugins): DiscoverStart { + start(core: CoreStart, plugins: DiscoverStartPlugins) { // we need to register the application service at setup, but to render it // there are some start dependencies necessary, for this reason // initializeInnerAngular + initializeServices are assigned at start and used @@ -198,7 +168,7 @@ export class DiscoverPlugin implements Plugin { if (this.servicesInitialized) { return { core, plugins }; } - const services = await buildServices(core, plugins, this.docViewsRegistry!); + const services = await buildServices(core, plugins); setServices(services); this.servicesInitialized = true; diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index c58a7d2fbb5cd..809022620e69d 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -271,6 +271,12 @@ export const npSetup = { }), }, }, + discover: { + docViews: { + addDocView: sinon.fake(), + setAngularInjectorGetter: sinon.fake(), + }, + }, visTypeVega: { config: sinon.fake(), }, @@ -459,6 +465,11 @@ export const npStart = { useChartsTheme: sinon.fake(), }, }, + discover: { + docViews: { + DocViewer: () => null, + }, + }, }, }; diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index deb8387fee29c..ee14f192a2149 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -65,6 +65,7 @@ import { NavigationPublicPluginStart, } from '../../../../plugins/navigation/public'; import { VisTypeVegaSetup } from '../../../../plugins/vis_type_vega/public'; +import { DiscoverSetup, DiscoverStart } from '../../../../plugins/discover/public'; export interface PluginsSetup { bfetch: BfetchPublicSetup; @@ -83,6 +84,7 @@ export interface PluginsSetup { advancedSettings: AdvancedSettingsSetup; management: ManagementSetup; visTypeVega: VisTypeVegaSetup; + discover: DiscoverSetup; telemetry?: TelemetryPluginSetup; } @@ -100,6 +102,7 @@ export interface PluginsStart { share: SharePluginStart; management: ManagementStart; advancedSettings: AdvancedSettingsStart; + discover: DiscoverStart; telemetry?: TelemetryPluginStart; } diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json new file mode 100644 index 0000000000000..91d6358d44c18 --- /dev/null +++ b/src/plugins/discover/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "discover", + "version": "kibana", + "server": false, + "ui": true +} diff --git a/src/plugins/discover/public/components/_index.scss b/src/plugins/discover/public/components/_index.scss new file mode 100644 index 0000000000000..ff50d4b5dca93 --- /dev/null +++ b/src/plugins/discover/public/components/_index.scss @@ -0,0 +1 @@ +@import 'doc_viewer/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap b/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap rename to src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap b/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap rename to src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_doc_viewer.scss b/src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_doc_viewer.scss rename to src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_index.scss b/src/plugins/discover/public/components/doc_viewer/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_index.scss rename to src/plugins/discover/public/components/doc_viewer/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx similarity index 81% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx index 15f0f40700abc..6f29f10ddd026 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx @@ -21,37 +21,33 @@ import { mount, shallow } from 'enzyme'; import { DocViewer } from './doc_viewer'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; -import { getServices } from '../../../kibana_services'; +import { getDocViewsRegistry } from '../../services'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; -jest.mock('../../../kibana_services', () => { +jest.mock('../../services', () => { let registry: any[] = []; return { - getServices: () => ({ - docViewsRegistry: { - addDocView(view: any) { - registry.push(view); - }, - getDocViewsSorted() { - return registry; - }, + getDocViewsRegistry: () => ({ + addDocView(view: any) { + registry.push(view); + }, + getDocViewsSorted() { + return registry; }, resetRegistry: () => { registry = []; }, }), - formatMsg: (x: any) => String(x), - formatStack: (x: any) => String(x), }; }); beforeEach(() => { - (getServices() as any).resetRegistry(); + (getDocViewsRegistry() as any).resetRegistry(); jest.clearAllMocks(); }); test('Render with 3 different tabs', () => { - const registry = getServices().docViewsRegistry; + const registry = getDocViewsRegistry(); registry.addDocView({ order: 10, title: 'Render function', render: jest.fn() }); registry.addDocView({ order: 20, title: 'React component', component: () =>
test
}); registry.addDocView({ order: 30, title: 'Invalid doc view' }); @@ -69,7 +65,7 @@ test('Render with 1 tab displaying error message', () => { return null; } - const registry = getServices().docViewsRegistry; + const registry = getDocViewsRegistry(); registry.addDocView({ order: 10, title: 'React component', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx index a177d8c29304c..792d9c44400d7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { EuiTabbedContent } from '@elastic/eui'; -import { getServices } from '../../../kibana_services'; +import { getDocViewsRegistry } from '../../services'; import { DocViewerTab } from './doc_viewer_tab'; import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; @@ -29,7 +29,7 @@ import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; * a `render` function. */ export function DocViewer(renderProps: DocViewRenderProps) { - const { docViewsRegistry } = getServices(); + const docViewsRegistry = getDocViewsRegistry(); const tabs = docViewsRegistry .getDocViewsSorted(renderProps.hit) .map(({ title, render, component }: DocView, idx: number) => { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx index 075217add7b52..387e57dc8a7e3 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; -import { formatMsg, formatStack } from '../../../kibana_services'; +import { formatMsg, formatStack } from '../../../../kibana_legacy/public'; interface Props { error: Error | string; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.test.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.test.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_tab.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/__snapshots__/field_name.test.tsx.snap b/src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/__snapshots__/field_name.test.tsx.snap rename to src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.test.tsx b/src/plugins/discover/public/components/field_name/field_name.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.test.tsx rename to src/plugins/discover/public/components/field_name/field_name.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx b/src/plugins/discover/public/components/field_name/field_name.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx rename to src/plugins/discover/public/components/field_name/field_name.tsx index 1b3b16332fa4f..63518aae28de6 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx +++ b/src/plugins/discover/public/components/field_name/field_name.tsx @@ -20,8 +20,8 @@ import React from 'react'; import classNames from 'classnames'; import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { FieldIcon, FieldIconProps } from '../../../../../../../../../plugins/kibana_react/public'; -import { shortenDottedString } from '../../../helpers'; +import { FieldIcon, FieldIconProps } from '../../../../kibana_react/public'; +import { shortenDottedString } from '../../helpers'; import { getFieldTypeName } from './field_type_name'; // property field is provided at discover's field chooser diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts b/src/plugins/discover/public/components/field_name/field_type_name.ts similarity index 66% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts rename to src/plugins/discover/public/components/field_name/field_type_name.ts index 0cf428ee48b9d..a67c20fc4f353 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts +++ b/src/plugins/discover/public/components/field_name/field_type_name.ts @@ -21,52 +21,52 @@ import { i18n } from '@kbn/i18n'; export function getFieldTypeName(type: string) { switch (type) { case 'boolean': - return i18n.translate('kbn.discover.fieldNameIcons.booleanAriaLabel', { + return i18n.translate('discover.fieldNameIcons.booleanAriaLabel', { defaultMessage: 'Boolean field', }); case 'conflict': - return i18n.translate('kbn.discover.fieldNameIcons.conflictFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.conflictFieldAriaLabel', { defaultMessage: 'Conflicting field', }); case 'date': - return i18n.translate('kbn.discover.fieldNameIcons.dateFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.dateFieldAriaLabel', { defaultMessage: 'Date field', }); case 'geo_point': - return i18n.translate('kbn.discover.fieldNameIcons.geoPointFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.geoPointFieldAriaLabel', { defaultMessage: 'Geo point field', }); case 'geo_shape': - return i18n.translate('kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.geoShapeFieldAriaLabel', { defaultMessage: 'Geo shape field', }); case 'ip': - return i18n.translate('kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.ipAddressFieldAriaLabel', { defaultMessage: 'IP address field', }); case 'murmur3': - return i18n.translate('kbn.discover.fieldNameIcons.murmur3FieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.murmur3FieldAriaLabel', { defaultMessage: 'Murmur3 field', }); case 'number': - return i18n.translate('kbn.discover.fieldNameIcons.numberFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.numberFieldAriaLabel', { defaultMessage: 'Number field', }); case 'source': // Note that this type is currently not provided, type for _source is undefined - return i18n.translate('kbn.discover.fieldNameIcons.sourceFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.sourceFieldAriaLabel', { defaultMessage: 'Source field', }); case 'string': - return i18n.translate('kbn.discover.fieldNameIcons.stringFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.stringFieldAriaLabel', { defaultMessage: 'String field', }); case 'nested': - return i18n.translate('kbn.discover.fieldNameIcons.nestedFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.nestedFieldAriaLabel', { defaultMessage: 'Nested field', }); default: - return i18n.translate('kbn.discover.fieldNameIcons.unknownFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.unknownFieldAriaLabel', { defaultMessage: 'Unknown field', }); } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap b/src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap rename to src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx b/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx rename to src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx index 9cab7974c9eb2..7e7f80c6aaa56 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx +++ b/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { JsonCodeBlock } from './json_code_block'; -import { IndexPattern } from '../../../kibana_services'; +import { IndexPattern } from '../../../../data/public'; it('returns the `JsonCodeEditor` component', () => { const props = { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx b/src/plugins/discover/public/components/json_code_block/json_code_block.tsx similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx rename to src/plugins/discover/public/components/json_code_block/json_code_block.tsx index 3331969e351ab..9297ab0dfcf4d 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx +++ b/src/plugins/discover/public/components/json_code_block/json_code_block.tsx @@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; export function JsonCodeBlock({ hit }: DocViewRenderProps) { - const label = i18n.translate('kbn.discover.docViews.json.codeEditorAriaLabel', { + const label = i18n.translate('discover.docViews.json.codeEditorAriaLabel', { defaultMessage: 'Read only JSON view of an elasticsearch document', }); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx b/src/plugins/discover/public/components/table/table.test.tsx similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx rename to src/plugins/discover/public/components/table/table.test.tsx index 386f405544a61..91e116c4c6696 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx +++ b/src/plugins/discover/public/components/table/table.test.tsx @@ -21,10 +21,7 @@ import { mount } from 'enzyme'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { DocViewTable } from './table'; - -import { IndexPattern, indexPatterns } from '../../../kibana_services'; - -jest.mock('ui/new_platform'); +import { indexPatterns, IndexPattern } from '../../../../data/public'; const indexPattern = { fields: [ diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.tsx b/src/plugins/discover/public/components/table/table.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.tsx rename to src/plugins/discover/public/components/table/table.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.test.ts b/src/plugins/discover/public/components/table/table_helper.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.test.ts rename to src/plugins/discover/public/components/table/table_helper.test.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.tsx b/src/plugins/discover/public/components/table/table_helper.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.tsx rename to src/plugins/discover/public/components/table/table_helper.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx b/src/plugins/discover/public/components/table/table_row.tsx similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx rename to src/plugins/discover/public/components/table/table_row.tsx index 5b13f6b3655c3..a4d5c57d10b33 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx +++ b/src/plugins/discover/public/components/table/table_row.tsx @@ -26,7 +26,7 @@ import { DocViewTableRowBtnCollapse } from './table_row_btn_collapse'; import { DocViewTableRowBtnFilterExists } from './table_row_btn_filter_exists'; import { DocViewTableRowIconNoMapping } from './table_row_icon_no_mapping'; import { DocViewTableRowIconUnderscore } from './table_row_icon_underscore'; -import { FieldName } from '../../angular/directives/field_name/field_name'; +import { FieldName } from '../field_name/field_name'; export interface Props { field: string; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx b/src/plugins/discover/public/components/table/table_row_btn_collapse.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx rename to src/plugins/discover/public/components/table/table_row_btn_collapse.tsx index e59f607329d4a..bb5ea4bd20f07 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx +++ b/src/plugins/discover/public/components/table/table_row_btn_collapse.tsx @@ -26,7 +26,7 @@ export interface Props { } export function DocViewTableRowBtnCollapse({ onClick, isCollapsed }: Props) { - const label = i18n.translate('kbn.discover.docViews.table.toggleFieldDetails', { + const label = i18n.translate('discover.docViews.table.toggleFieldDetails', { defaultMessage: 'Toggle field details', }); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx b/src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx rename to src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx index 8e2668e26cf08..bd842eb5c6f72 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx +++ b/src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx @@ -29,12 +29,12 @@ export interface Props { export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props) { const tooltipContent = disabled ? ( ) : ( ); @@ -42,7 +42,7 @@ export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props return ( ) : ( ) ) : ( ); @@ -54,12 +54,9 @@ export function DocViewTableRowBtnFilterExists({ return ( ) : ( ); @@ -42,7 +42,7 @@ export function DocViewTableRowBtnFilterRemove({ onClick, disabled = false }: Pr return ( } > Index Patterns page', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx b/src/plugins/discover/public/components/table/table_row_icon_underscore.tsx similarity index 89% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx rename to src/plugins/discover/public/components/table/table_row_icon_underscore.tsx index 724b5712cf1fe..791ab18de5175 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx +++ b/src/plugins/discover/public/components/table/table_row_icon_underscore.tsx @@ -22,13 +22,13 @@ import { i18n } from '@kbn/i18n'; export function DocViewTableRowIconUnderscore() { const ariaLabel = i18n.translate( - 'kbn.discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedAriaLabel', + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedAriaLabel', { defaultMessage: 'Warning', } ); const tooltipContent = i18n.translate( - 'kbn.discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedTooltip', + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedTooltip', { defaultMessage: 'Field names beginning with {underscoreSign} are not supported', values: { underscoreSign: '_' }, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_helpers.tsx b/src/plugins/discover/public/doc_views/doc_views_helpers.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_helpers.tsx rename to src/plugins/discover/public/doc_views/doc_views_helpers.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts b/src/plugins/discover/public/doc_views/doc_views_registry.ts similarity index 82% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts rename to src/plugins/discover/public/doc_views/doc_views_registry.ts index 91acf1c7ac4ae..8f4518538be72 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts +++ b/src/plugins/discover/public/doc_views/doc_views_registry.ts @@ -23,8 +23,11 @@ import { DocView, DocViewInput, ElasticSearchHit, DocViewInputFn } from './doc_v export class DocViewsRegistry { private docViews: DocView[] = []; + private angularInjectorGetter: (() => Promise) | null = null; - constructor(private getInjector: () => Promise) {} + setAngularInjectorGetter(injectorGetter: () => Promise) { + this.angularInjectorGetter = injectorGetter; + } /** * Extends and adds the given doc view to the registry array @@ -33,7 +36,12 @@ export class DocViewsRegistry { const docView = typeof docViewRaw === 'function' ? docViewRaw() : docViewRaw; if (docView.directive) { // convert angular directive to render function for backwards compatibility - docView.render = convertDirectiveToRenderFn(docView.directive, this.getInjector); + docView.render = convertDirectiveToRenderFn(docView.directive, () => { + if (!this.angularInjectorGetter) { + throw new Error('Angular was not initialized'); + } + return this.angularInjectorGetter(); + }); } if (typeof docView.shouldShow !== 'function') { docView.shouldShow = () => true; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts b/src/plugins/discover/public/doc_views/doc_views_types.ts similarity index 90% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts rename to src/plugins/discover/public/doc_views/doc_views_types.ts index a7828f9f0e7ed..0a4b5bb570bd7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/doc_views/doc_views_types.ts @@ -18,10 +18,10 @@ */ import { ComponentType } from 'react'; import { IScope } from 'angular'; -import { IndexPattern } from '../../kibana_services'; +import { IndexPattern } from '../../../data/public'; export interface AngularDirective { - controller: (scope: AngularScope) => void; + controller: (...injectedServices: any[]) => void; template: string; } @@ -51,13 +51,14 @@ export interface DocViewRenderProps { onAddColumn?: (columnName: string) => void; onRemoveColumn?: (columnName: string) => void; } +export type DocViewerComponent = ComponentType; export type DocViewRenderFn = ( domeNode: HTMLDivElement, renderProps: DocViewRenderProps ) => () => void; export interface DocViewInput { - component?: ComponentType; + component?: DocViewerComponent; directive?: AngularDirective; order: number; render?: DocViewRenderFn; diff --git a/src/plugins/discover/public/helpers/index.ts b/src/plugins/discover/public/helpers/index.ts new file mode 100644 index 0000000000000..7196c96989e97 --- /dev/null +++ b/src/plugins/discover/public/helpers/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { shortenDottedString } from './shorten_dotted_string'; diff --git a/src/plugins/discover/public/helpers/shorten_dotted_string.ts b/src/plugins/discover/public/helpers/shorten_dotted_string.ts new file mode 100644 index 0000000000000..9d78a96784339 --- /dev/null +++ b/src/plugins/discover/public/helpers/shorten_dotted_string.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const DOT_PREFIX_RE = /(.).+?\./g; + +/** + * Convert a dot.notated.string into a short + * version (d.n.string) + */ +export const shortenDottedString = (input: string) => input.replace(DOT_PREFIX_RE, '$1.'); diff --git a/src/plugins/discover/public/index.scss b/src/plugins/discover/public/index.scss new file mode 100644 index 0000000000000..841415620d691 --- /dev/null +++ b/src/plugins/discover/public/index.scss @@ -0,0 +1 @@ +@import 'components/index'; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index c5050147c3d5a..dbc361ee59f49 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -17,5 +17,18 @@ * under the License. */ +import { DiscoverPlugin } from './plugin'; + +export { DiscoverSetup, DiscoverStart } from './plugin'; +export { DocViewTable } from './components/table/table'; +export { JsonCodeBlock } from './components/json_code_block/json_code_block'; +export { DocViewInput, DocViewInputFn, DocViewerComponent } from './doc_views/doc_views_types'; +export { FieldName } from './components/field_name/field_name'; +export * from './doc_views/doc_views_types'; + +export function plugin() { + return new DiscoverPlugin(); +} + export { createSavedSearchesLoader } from './saved_searches/saved_searches'; export { SavedSearchLoader, SavedSearch } from './saved_searches/types'; diff --git a/src/plugins/discover/public/mocks.ts b/src/plugins/discover/public/mocks.ts new file mode 100644 index 0000000000000..bb05e3d412001 --- /dev/null +++ b/src/plugins/discover/public/mocks.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DiscoverSetup, DiscoverStart } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = { + docViews: { + addDocView: jest.fn(), + setAngularInjectorGetter: jest.fn(), + }, + }; + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = { + docViews: { + DocViewer: jest.fn(() => null), + }, + }; + return startContract; +}; + +export const discoverPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts new file mode 100644 index 0000000000000..d2797586bfdfb --- /dev/null +++ b/src/plugins/discover/public/plugin.ts @@ -0,0 +1,110 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { auto } from 'angular'; +import { CoreSetup, Plugin } from 'kibana/public'; +import { DocViewInput, DocViewInputFn, DocViewRenderProps } from './doc_views/doc_views_types'; +import { DocViewsRegistry } from './doc_views/doc_views_registry'; +import { DocViewTable } from './components/table/table'; +import { JsonCodeBlock } from './components/json_code_block/json_code_block'; +import { DocViewer } from './components/doc_viewer/doc_viewer'; +import { setDocViewsRegistry } from './services'; + +import './index.scss'; + +/** + * @public + */ +export interface DiscoverSetup { + docViews: { + /** + * Add new doc view shown along with table view and json view in the details of each document in Discover. + * Both react and angular doc views are supported. + * @param docViewRaw + */ + addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; + /** + * Set the angular injector for bootstrapping angular doc views. This is only exposed temporarily to aid + * migration to the new platform and will be removed soon. + * @deprecated + * @param injectorGetter + */ + setAngularInjectorGetter(injectorGetter: () => Promise): void; + }; +} +/** + * @public + */ +export interface DiscoverStart { + docViews: { + /** + * Component rendering all the doc views for a given document. + * This is only exposed temporarily to aid migration to the new platform and will be removed soon. + * @deprecated + */ + DocViewer: React.ComponentType; + }; +} + +/** + * Contains Discover, one of the oldest parts of Kibana + * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular + * Discover provides embeddables, those contain a slimmer Angular + */ +export class DiscoverPlugin implements Plugin { + private docViewsRegistry: DocViewsRegistry | null = null; + + setup(core: CoreSetup): DiscoverSetup { + this.docViewsRegistry = new DocViewsRegistry(); + setDocViewsRegistry(this.docViewsRegistry); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.table.tableTitle', { + defaultMessage: 'Table', + }), + order: 10, + component: DocViewTable, + }); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.json.jsonTitle', { + defaultMessage: 'JSON', + }), + order: 20, + component: JsonCodeBlock, + }); + + return { + docViews: { + addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), + setAngularInjectorGetter: this.docViewsRegistry.setAngularInjectorGetter.bind( + this.docViewsRegistry + ), + }, + }; + } + + start() { + return { + docViews: { + DocViewer, + }, + }; + } +} diff --git a/src/plugins/discover/public/saved_searches/_saved_search.ts b/src/plugins/discover/public/saved_searches/_saved_search.ts index 72983b7835eee..56360b04a49c8 100644 --- a/src/plugins/discover/public/saved_searches/_saved_search.ts +++ b/src/plugins/discover/public/saved_searches/_saved_search.ts @@ -16,7 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { createSavedObjectClass, SavedObjectKibanaServices } from '../../../saved_objects/public'; +import { + createSavedObjectClass, + SavedObject, + SavedObjectKibanaServices, +} from '../../../saved_objects/public'; export function createSavedSearchClass(services: SavedObjectKibanaServices) { const SavedObjectClass = createSavedObjectClass(services); @@ -66,5 +70,5 @@ export function createSavedSearchClass(services: SavedObjectKibanaServices) { } } - return SavedSearch; + return SavedSearch as new (id: string) => SavedObject; } diff --git a/src/plugins/discover/public/services.ts b/src/plugins/discover/public/services.ts new file mode 100644 index 0000000000000..3a28759d82b71 --- /dev/null +++ b/src/plugins/discover/public/services.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createGetterSetter } from '../../kibana_utils/common'; +import { DocViewsRegistry } from './doc_views/doc_views_registry'; + +export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( + 'DocViewsRegistry' +); diff --git a/test/plugin_functional/config.js b/test/plugin_functional/config.js index e63054f1b6912..7017c01cc5634 100644 --- a/test/plugin_functional/config.js +++ b/test/plugin_functional/config.js @@ -39,6 +39,7 @@ export default async function({ readConfigFile }) { require.resolve('./test_suites/core_plugins'), require.resolve('./test_suites/management'), require.resolve('./test_suites/bfetch_explorer'), + require.resolve('./test_suites/doc_views'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/doc_views_plugin/kibana.json b/test/plugin_functional/plugins/doc_views_plugin/kibana.json new file mode 100644 index 0000000000000..f8596aad01e87 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "docViewPlugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["discover"] +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/package.json b/test/plugin_functional/plugins/doc_views_plugin/package.json new file mode 100644 index 0000000000000..0cef1bf65c0e8 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "docViewPlugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/doc_views_plugin", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/public/index.ts b/test/plugin_functional/plugins/doc_views_plugin/public/index.ts new file mode 100644 index 0000000000000..8097226180763 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/public/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DocViewsPlugin } from './plugin'; + +export const plugin = () => new DocViewsPlugin(); diff --git a/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx b/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx new file mode 100644 index 0000000000000..4b9823fda3673 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import angular from 'angular'; +import React from 'react'; +import { Plugin, CoreSetup } from 'kibana/public'; +import { DiscoverSetup } from '../../../../../src/plugins/discover/public'; + +angular.module('myDocView', []).directive('myHit', () => ({ + restrict: 'E', + scope: { + hit: '=hit', + }, + template: '

{{hit._index}}

', +})); + +function MyHit(props: { index: string }) { + return

{props.index}

; +} + +export class DocViewsPlugin implements Plugin { + public setup(core: CoreSetup, { discover }: { discover: DiscoverSetup }) { + discover.docViews.addDocView({ + directive: { + controller: function MyController($injector: any) { + $injector.loadNewModules(['myDocView']); + }, + template: ``, + }, + order: 1, + title: 'Angular doc view', + }); + + discover.docViews.addDocView({ + component: props => { + return ; + }, + order: 2, + title: 'React doc view', + }); + } + + public start() {} +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json new file mode 100644 index 0000000000000..4a564ee1e5578 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*" + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/doc_views/doc_views.ts b/test/plugin_functional/test_suites/doc_views/doc_views.ts new file mode 100644 index 0000000000000..8764f45c2c076 --- /dev/null +++ b/test/plugin_functional/test_suites/doc_views/doc_views.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + + describe('custom doc views', function() { + before(async () => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + it('should show custom doc views', async () => { + await testSubjects.click('docTableExpandToggleColumn'); + const angularTab = await find.byButtonText('Angular doc view'); + const reactTab = await find.byButtonText('React doc view'); + expect(await angularTab.isDisplayed()).to.be(true); + expect(await reactTab.isDisplayed()).to.be(true); + }); + + it('should render angular doc view', async () => { + const angularTab = await find.byButtonText('Angular doc view'); + await angularTab.click(); + const angularContent = await testSubjects.find('angular-docview'); + expect(await angularContent.getVisibleText()).to.be('logstash-2015.09.22'); + }); + + it('should render react doc view', async () => { + const reactTab = await find.byButtonText('React doc view'); + await reactTab.click(); + const reactContent = await testSubjects.find('react-docview'); + expect(await reactContent.getVisibleText()).to.be('logstash-2015.09.22'); + }); + }); +} diff --git a/test/plugin_functional/test_suites/doc_views/index.ts b/test/plugin_functional/test_suites/doc_views/index.ts new file mode 100644 index 0000000000000..dee3a72e3f2c6 --- /dev/null +++ b/test/plugin_functional/test_suites/doc_views/index.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { PluginFunctionalProviderContext } from '../../services'; + +export default function({ getService, loadTestFile }: PluginFunctionalProviderContext) { + const esArchiver = getService('esArchiver'); + + describe('doc views', function() { + before(async () => { + await esArchiver.loadIfNeeded('../functional/fixtures/es_archiver/discover'); + }); + + loadTestFile(require.resolve('./doc_views')); + }); +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e8d93ba6d3200..fe7ad863945c5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -464,6 +464,17 @@ "data.search.searchBar.savedQueryPopoverSavedQueryListItemDescriptionAriaLabel": "{savedQueryName} の説明", "data.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel": "選択されたクエリボタン {savedQueryName} を保存しました。変更を破棄するには押してください。", "data.search.searchBar.savedQueryPopoverTitleText": "保存されたクエリ", + "discover.fieldNameIcons.booleanAriaLabel": "ブールフィールド", + "discover.fieldNameIcons.conflictFieldAriaLabel": "矛盾フィールド", + "discover.fieldNameIcons.dateFieldAriaLabel": "日付フィールド", + "discover.fieldNameIcons.geoPointFieldAriaLabel": "地理ポイント", + "discover.fieldNameIcons.geoShapeFieldAriaLabel": "地理情報図形", + "discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP アドレスフィールド", + "discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 フィールド", + "discover.fieldNameIcons.numberFieldAriaLabel": "数値フィールド", + "discover.fieldNameIcons.sourceFieldAriaLabel": "ソースフィールド", + "discover.fieldNameIcons.stringFieldAriaLabel": "文字列フィールド", + "discover.fieldNameIcons.unknownFieldAriaLabel": "不明なフィールド", "charts.colormaps.bluesText": "青", "charts.colormaps.greensText": "緑", "charts.colormaps.greenToRedText": "緑から赤", @@ -1075,17 +1086,6 @@ "kbn.discover.fieldChooser.searchPlaceHolder": "検索フィールド", "kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "フィールド設定を非表示", "kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "フィールド設定を表示", - "kbn.discover.fieldNameIcons.booleanAriaLabel": "ブールフィールド", - "kbn.discover.fieldNameIcons.conflictFieldAriaLabel": "矛盾フィールド", - "kbn.discover.fieldNameIcons.dateFieldAriaLabel": "日付フィールド", - "kbn.discover.fieldNameIcons.geoPointFieldAriaLabel": "地理ポイント", - "kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel": "地理情報図形", - "kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP アドレスフィールド", - "kbn.discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 フィールド", - "kbn.discover.fieldNameIcons.numberFieldAriaLabel": "数値フィールド", - "kbn.discover.fieldNameIcons.sourceFieldAriaLabel": "ソースフィールド", - "kbn.discover.fieldNameIcons.stringFieldAriaLabel": "文字列フィールド", - "kbn.discover.fieldNameIcons.unknownFieldAriaLabel": "不明なフィールド", "kbn.discover.histogram.partialData.bucketTooltipText": "選択された時間範囲にはこのバケット全体は含まれていませんが、一部データが含まれている可能性があります。", "kbn.discover.histogramOfFoundDocumentsAriaLabel": "発見されたドキュメントのヒストグラム", "kbn.discover.hitsPluralTitle": "{hits, plural, one {ヒット} other {ヒット}}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cfab424935c6d..e1cfa5e4ef358 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -464,6 +464,17 @@ "data.search.searchBar.savedQueryPopoverSavedQueryListItemDescriptionAriaLabel": "{savedQueryName} 描述", "data.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel": "已保存查询按钮已选择 {savedQueryName}。按下可清除任何更改。", "data.search.searchBar.savedQueryPopoverTitleText": "已保存查询", + "discover.fieldNameIcons.booleanAriaLabel": "布尔字段", + "discover.fieldNameIcons.conflictFieldAriaLabel": "冲突字段", + "discover.fieldNameIcons.dateFieldAriaLabel": "日期字段", + "discover.fieldNameIcons.geoPointFieldAriaLabel": "地理位置点字段", + "discover.fieldNameIcons.geoShapeFieldAriaLabel": "几何形状字段", + "discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP 地址字段", + "discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 字段", + "discover.fieldNameIcons.numberFieldAriaLabel": "数字字段", + "discover.fieldNameIcons.sourceFieldAriaLabel": "源字段", + "discover.fieldNameIcons.stringFieldAriaLabel": "字符串字段", + "discover.fieldNameIcons.unknownFieldAriaLabel": "未知字段", "charts.colormaps.bluesText": "蓝色", "charts.colormaps.greensText": "绿色", "charts.colormaps.greenToRedText": "绿到红", @@ -1075,17 +1086,6 @@ "kbn.discover.fieldChooser.searchPlaceHolder": "搜索字段", "kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "隐藏字段设置", "kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "显示字段设置", - "kbn.discover.fieldNameIcons.booleanAriaLabel": "布尔字段", - "kbn.discover.fieldNameIcons.conflictFieldAriaLabel": "冲突字段", - "kbn.discover.fieldNameIcons.dateFieldAriaLabel": "日期字段", - "kbn.discover.fieldNameIcons.geoPointFieldAriaLabel": "地理位置点字段", - "kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel": "几何形状字段", - "kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP 地址字段", - "kbn.discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 字段", - "kbn.discover.fieldNameIcons.numberFieldAriaLabel": "数字字段", - "kbn.discover.fieldNameIcons.sourceFieldAriaLabel": "源字段", - "kbn.discover.fieldNameIcons.stringFieldAriaLabel": "字符串字段", - "kbn.discover.fieldNameIcons.unknownFieldAriaLabel": "未知字段", "kbn.discover.histogram.partialData.bucketTooltipText": "选定的时间范围不包括此整个存储桶,其可能包含部分数据。", "kbn.discover.histogramOfFoundDocumentsAriaLabel": "已找到文档的直方图", "kbn.discover.hitsPluralTitle": "{hits, plural, one {次命中} other {次命中}}", From e6dbc3fc21c89dc82ddf69221695543e4bfa0a4d Mon Sep 17 00:00:00 2001 From: Tudor Golubenco Date: Tue, 24 Mar 2020 08:10:10 +0100 Subject: [PATCH 11/64] [SIEM] Updates process and TLS tables to use ECS 1.5 fields (#60854) * Added new process filter * Use new ECS TLS fields --- .../__snapshots__/index.test.tsx.snap | 35 +- .../page/network/tls_table/columns.tsx | 26 +- .../components/page/network/tls_table/mock.ts | 15 +- .../page/network/tls_table/translations.ts | 2 +- .../public/containers/tls/index.gql_query.ts | 5 +- .../siem/public/graphql/introspection.json | 20 +- .../plugins/siem/public/graphql/types.ts | 12 +- .../siem/server/graphql/tls/schema.gql.ts | 5 +- .../plugins/siem/server/graphql/types.ts | 21 +- .../server/lib/tls/elasticsearch_adapter.ts | 5 +- .../plugins/siem/server/lib/tls/mock.ts | 274 +- .../siem/server/lib/tls/query_tls.dsl.ts | 21 +- .../plugins/siem/server/lib/tls/types.ts | 8 +- .../lib/uncommon_processes/query.dsl.ts | 16 + x-pack/test/api_integration/apis/siem/tls.ts | 68 +- .../es_archives/packetbeat/tls/data.json.gz | Bin 0 -> 3929 bytes .../es_archives/packetbeat/tls/mappings.json | 9583 +++++++++++++++++ 17 files changed, 9739 insertions(+), 377 deletions(-) create mode 100644 x-pack/test/functional/es_archives/packetbeat/tls/data.json.gz create mode 100644 x-pack/test/functional/es_archives/packetbeat/tls/mappings.json diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap index 85b028cf7cd51..8b7d8efa7ac37 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap @@ -10,14 +10,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "2fe3bdf168af35b9e0ce5dc583bab007c40d47de", - "alternativeNames": Array [ - "*.elastic.co", - "elastic.co", - ], - "commonNames": Array [ - "*.elastic.co", - ], - "issuerNames": Array [ + "issuers": Array [ "DigiCert SHA2 Secure Server CA", ], "ja3": Array [ @@ -27,6 +20,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2021-04-22T12:00:00.000Z", ], + "subjects": Array [ + "*.elastic.co", + ], }, }, Object { @@ -35,13 +31,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "61749734b3246f1584029deb4f5276c64da00ada", - "alternativeNames": Array [ - "api.snapcraft.io", - ], - "commonNames": Array [ - "api.snapcraft.io", - ], - "issuerNames": Array [ + "issuers": Array [ "DigiCert SHA2 Secure Server CA", ], "ja3": Array [ @@ -50,6 +40,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2019-05-22T12:00:00.000Z", ], + "subjects": Array [ + "api.snapcraft.io", + ], }, }, Object { @@ -58,14 +51,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "6560d3b7dd001c989b85962fa64beb778cdae47a", - "alternativeNames": Array [ - "changelogs.ubuntu.com", - "manpages.ubuntu.com", - ], - "commonNames": Array [ - "changelogs.ubuntu.com", - ], - "issuerNames": Array [ + "issuers": Array [ "Let's Encrypt Authority X3", ], "ja3": Array [ @@ -74,6 +60,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2019-06-27T01:09:59.000Z", ], + "subjects": Array [ + "changelogs.ubuntu.com", + ], }, }, ] diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx index 44a538871d951..f95475819abc9 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx @@ -32,11 +32,11 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ truncateText: false, hideForMobile: false, sortable: false, - render: ({ _id, issuerNames }) => + render: ({ _id, issuers }) => getRowItemDraggables({ - rowItems: issuerNames, - attrName: 'tls.server_certificate.issuer.common_name', - idPrefix: `${tableId}-${_id}-table-issuerNames`, + rowItems: issuers, + attrName: 'tls.server.issuer', + idPrefix: `${tableId}-${_id}-table-issuers`, }), }, { @@ -45,18 +45,12 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ truncateText: false, hideForMobile: false, sortable: false, - render: ({ _id, alternativeNames, commonNames }) => - alternativeNames != null && alternativeNames.length > 0 - ? getRowItemDraggables({ - rowItems: alternativeNames, - attrName: 'tls.server_certificate.alternative_names', - idPrefix: `${tableId}-${_id}-table-alternative-name`, - }) - : getRowItemDraggables({ - rowItems: commonNames, - attrName: 'tls.server_certificate.subject.common_name', - idPrefix: `${tableId}-${_id}-table-common-name`, - }), + render: ({ _id, subjects }) => + getRowItemDraggables({ + rowItems: subjects, + attrName: 'tls.server.subject', + idPrefix: `${tableId}-${_id}-table-subjects`, + }), }, { field: 'node._id', diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts index 77148bf50c038..453bd8fc84dfa 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts @@ -12,10 +12,9 @@ export const mockTlsData: TlsData = { { node: { _id: '2fe3bdf168af35b9e0ce5dc583bab007c40d47de', - alternativeNames: ['*.elastic.co', 'elastic.co'], - commonNames: ['*.elastic.co'], + subjects: ['*.elastic.co'], ja3: ['7851693188210d3b271aa1713d8c68c2', 'fb4726d465c5f28b84cd6d14cedd13a7'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + issuers: ['DigiCert SHA2 Secure Server CA'], notAfter: ['2021-04-22T12:00:00.000Z'], }, cursor: { @@ -25,10 +24,9 @@ export const mockTlsData: TlsData = { { node: { _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], + subjects: ['api.snapcraft.io'], ja3: ['839868ad711dc55bde0d37a87f14740d'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + issuers: ['DigiCert SHA2 Secure Server CA'], notAfter: ['2019-05-22T12:00:00.000Z'], }, cursor: { @@ -38,10 +36,9 @@ export const mockTlsData: TlsData = { { node: { _id: '6560d3b7dd001c989b85962fa64beb778cdae47a', - alternativeNames: ['changelogs.ubuntu.com', 'manpages.ubuntu.com'], - commonNames: ['changelogs.ubuntu.com'], + subjects: ['changelogs.ubuntu.com'], ja3: ['da12c94da8021bbaf502907ad086e7bc'], - issuerNames: ["Let's Encrypt Authority X3"], + issuers: ["Let's Encrypt Authority X3"], notAfter: ['2019-06-27T01:09:59.000Z'], }, cursor: { diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts index 89d0f58684cbe..ff714204144ec 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts @@ -16,7 +16,7 @@ export const TRANSPORT_LAYER_SECURITY = i18n.translate( export const UNIT = (totalCount: number) => i18n.translate('xpack.siem.network.ipDetails.tlsTable.unit', { values: { totalCount }, - defaultMessage: `{totalCount, plural, =1 {issuer} other {issuers}}`, + defaultMessage: `{totalCount, plural, =1 {server certificate} other {server certificates}}`, }); // Columns diff --git a/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts b/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts index bbb92282bee83..f513a94d69667 100644 --- a/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts +++ b/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts @@ -33,10 +33,9 @@ export const tlsQuery = gql` edges { node { _id - alternativeNames - commonNames + subjects ja3 - issuerNames + issuers notAfter } cursor { diff --git a/x-pack/legacy/plugins/siem/public/graphql/introspection.json b/x-pack/legacy/plugins/siem/public/graphql/introspection.json index 9802a5f5bd3bf..5d43024625d0d 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/siem/public/graphql/introspection.json @@ -9213,22 +9213,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "alternativeNames", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "notAfter", "description": "", @@ -9246,7 +9230,7 @@ "deprecationReason": null }, { - "name": "commonNames", + "name": "subjects", "description": "", "args": [], "type": { @@ -9278,7 +9262,7 @@ "deprecationReason": null }, { - "name": "issuerNames", + "name": "issuers", "description": "", "args": [], "type": { diff --git a/x-pack/legacy/plugins/siem/public/graphql/types.ts b/x-pack/legacy/plugins/siem/public/graphql/types.ts index 3528ee6e13a38..a5d1e3fbcba27 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/public/graphql/types.ts @@ -1859,15 +1859,13 @@ export interface TlsNode { timestamp?: Maybe; - alternativeNames?: Maybe; - notAfter?: Maybe; - commonNames?: Maybe; + subjects?: Maybe; ja3?: Maybe; - issuerNames?: Maybe; + issuers?: Maybe; } export interface UncommonProcessesData { @@ -5679,13 +5677,11 @@ export namespace GetTlsQuery { _id: Maybe; - alternativeNames: Maybe; - - commonNames: Maybe; + subjects: Maybe; ja3: Maybe; - issuerNames: Maybe; + issuers: Maybe; notAfter: Maybe; }; diff --git a/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts index 301960cea33ef..452c615c65aa5 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts @@ -13,11 +13,10 @@ export const tlsSchema = gql` type TlsNode { _id: String timestamp: Date - alternativeNames: [String!] notAfter: [String!] - commonNames: [String!] + subjects: [String!] ja3: [String!] - issuerNames: [String!] + issuers: [String!] } input TlsSortField { field: TlsFields! diff --git a/x-pack/legacy/plugins/siem/server/graphql/types.ts b/x-pack/legacy/plugins/siem/server/graphql/types.ts index f42da48f2c1da..e2b365f8bfa5b 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/types.ts @@ -1861,15 +1861,13 @@ export interface TlsNode { timestamp?: Maybe; - alternativeNames?: Maybe; - notAfter?: Maybe; - commonNames?: Maybe; + subjects?: Maybe; ja3?: Maybe; - issuerNames?: Maybe; + issuers?: Maybe; } export interface UncommonProcessesData { @@ -7824,15 +7822,13 @@ export namespace TlsNodeResolvers { timestamp?: TimestampResolver, TypeParent, TContext>; - alternativeNames?: AlternativeNamesResolver, TypeParent, TContext>; - notAfter?: NotAfterResolver, TypeParent, TContext>; - commonNames?: CommonNamesResolver, TypeParent, TContext>; + subjects?: SubjectsResolver, TypeParent, TContext>; ja3?: Ja3Resolver, TypeParent, TContext>; - issuerNames?: IssuerNamesResolver, TypeParent, TContext>; + issuers?: IssuersResolver, TypeParent, TContext>; } export type _IdResolver, Parent = TlsNode, TContext = SiemContext> = Resolver< @@ -7845,17 +7841,12 @@ export namespace TlsNodeResolvers { Parent = TlsNode, TContext = SiemContext > = Resolver; - export type AlternativeNamesResolver< - R = Maybe, - Parent = TlsNode, - TContext = SiemContext - > = Resolver; export type NotAfterResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext > = Resolver; - export type CommonNamesResolver< + export type SubjectsResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext @@ -7865,7 +7856,7 @@ export namespace TlsNodeResolvers { Parent, TContext >; - export type IssuerNamesResolver< + export type IssuersResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts b/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts index 716eea3f8df5b..10929c3d03641 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts @@ -66,10 +66,9 @@ export const formatTlsEdges = (buckets: TlsBuckets[]): TlsEdges[] => { const edge: TlsEdges = { node: { _id: bucket.key, - alternativeNames: bucket.alternative_names.buckets.map(({ key }) => key), - commonNames: bucket.common_names.buckets.map(({ key }) => key), + subjects: bucket.subjects.buckets.map(({ key }) => key), ja3: bucket.ja3.buckets.map(({ key }) => key), - issuerNames: bucket.issuer_names.buckets.map(({ key }) => key), + issuers: bucket.issuers.buckets.map(({ key }) => key), // eslint-disable-next-line @typescript-eslint/camelcase notAfter: bucket.not_after.buckets.map(({ key_as_string }) => key_as_string), }, diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts b/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts index 4b27d541ec992..b97a6fa509ef2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts @@ -20,11 +20,10 @@ export const mockTlsQuery = { order: { _key: 'desc' }, }, aggs: { - issuer_names: { terms: { field: 'tls.server_certificate.issuer.common_name' } }, - common_names: { terms: { field: 'tls.server_certificate.subject.common_name' } }, - alternative_names: { terms: { field: 'tls.server_certificate.alternative_names' } }, - not_after: { terms: { field: 'tls.server_certificate.not_after' } }, - ja3: { terms: { field: 'tls.fingerprints.ja3.hash' } }, + issuers: { terms: { field: 'tls.server.issuer' } }, + subjects: { terms: { field: 'tls.server.subject' } }, + not_after: { terms: { field: 'tls.server.not_after' } }, + ja3: { terms: { field: 'tls.server.ja3s' } }, }, }, }, @@ -44,16 +43,8 @@ export const expectedTlsEdges = [ }, node: { _id: 'fff8dc95436e0e25ce46b1526a1a547e8cf3bb82', - alternativeNames: [ - '*.1.nflxso.net', - '*.a.nflxso.net', - 'assets.nflxext.com', - 'cast.netflix.com', - 'codex.nflxext.com', - 'tvui.netflix.com', - ], - commonNames: ['*.1.nflxso.net'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + subjects: ['*.1.nflxso.net'], + issuers: ['DigiCert SHA2 Secure Server CA'], ja3: ['95d2dd53a89b334cddd5c22e81e7fe61'], notAfter: ['2019-10-27T12:00:00.000Z'], }, @@ -65,9 +56,8 @@ export const expectedTlsEdges = [ }, node: { _id: 'fd8440c4b20978b173e0910e2639d114f0d405c5', - alternativeNames: ['*.cogocast.net', 'cogocast.net'], - commonNames: ['cogocast.net'], - issuerNames: ['Amazon'], + subjects: ['cogocast.net'], + issuers: ['Amazon'], ja3: ['a111d93cdf31f993c40a8a9ef13e8d7e'], notAfter: ['2020-02-01T12:00:00.000Z'], }, @@ -76,12 +66,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fcdc16645ebb3386adc96e7ba735c4745709b9dd' }, node: { _id: 'fcdc16645ebb3386adc96e7ba735c4745709b9dd', - alternativeNames: [ - 'player-devintever2-imperva.mountain.siriusxm.com', - 'player-devintever2.mountain.siriusxm.com', - ], - commonNames: ['player-devintever2.mountain.siriusxm.com'], - issuerNames: ['Trustwave Organization Validation SHA256 CA, Level 1'], + subjects: ['player-devintever2.mountain.siriusxm.com'], + issuers: ['Trustwave Organization Validation SHA256 CA, Level 1'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-03-06T21:57:09.000Z'], }, @@ -90,15 +76,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fccf375789cb7e671502a7b0cc969f218a4b2c70' }, node: { _id: 'fccf375789cb7e671502a7b0cc969f218a4b2c70', - alternativeNames: [ - 'appleid-nc-s.apple.com', - 'appleid-nwk-s.apple.com', - 'appleid-prn-s.apple.com', - 'appleid-rno-s.apple.com', - 'appleid.apple.com', - ], - commonNames: ['appleid.apple.com'], - issuerNames: ['DigiCert SHA2 Extended Validation Server CA'], + subjects: ['appleid.apple.com'], + issuers: ['DigiCert SHA2 Extended Validation Server CA'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-07-04T12:00:00.000Z'], }, @@ -107,20 +86,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fc4a296b706fa18ac50b96f5c0327c69db4a8981' }, node: { _id: 'fc4a296b706fa18ac50b96f5c0327c69db4a8981', - alternativeNames: [ - 'api.itunes.apple.com', - 'appsto.re', - 'ax.init.itunes.apple.com', - 'bag.itunes.apple.com', - 'bookkeeper.itunes.apple.com', - 'c.itunes.apple.com', - 'carrierbundle.itunes.apple.com', - 'client-api.itunes.apple.com', - 'cma.itunes.apple.com', - 'courses.apple.com', - ], - commonNames: ['itunes.apple.com'], - issuerNames: ['DigiCert SHA2 Extended Validation Server CA'], + subjects: ['itunes.apple.com'], + issuers: ['DigiCert SHA2 Extended Validation Server CA'], ja3: ['a441a33aaee795f498d6b764cc78989a'], notAfter: ['2020-03-24T12:00:00.000Z'], }, @@ -129,20 +96,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fc2cbc41f6a0e9c0118de4fe40f299f7207b797e' }, node: { _id: 'fc2cbc41f6a0e9c0118de4fe40f299f7207b797e', - alternativeNames: [ - '*.adlercasino.com', - '*.allaustraliancasino.com', - '*.alletf.com', - '*.appareldesignpartners.com', - '*.atmosfir.net', - '*.cityofboston.gov', - '*.cp.mytoyotaentune.com', - '*.decathlon.be', - '*.decathlon.co.uk', - '*.decathlon.de', - ], - commonNames: ['incapsula.com'], - issuerNames: ['GlobalSign CloudSSL CA - SHA256 - G3'], + subjects: ['incapsula.com'], + issuers: ['GlobalSign CloudSSL CA - SHA256 - G3'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-04-04T14:05:06.000Z'], }, @@ -151,9 +106,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fb70d78ffa663a3a4374d841b3288d2de9759566' }, node: { _id: 'fb70d78ffa663a3a4374d841b3288d2de9759566', - alternativeNames: ['*.siriusxm.com', 'siriusxm.com'], - commonNames: ['*.siriusxm.com'], - issuerNames: ['DigiCert Baltimore CA-2 G2'], + subjects: ['*.siriusxm.com'], + issuers: ['DigiCert Baltimore CA-2 G2'], ja3: ['535aca3d99fc247509cd50933cd71d37', '6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2021-10-27T12:00:00.000Z'], }, @@ -162,16 +116,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fb59038dcec33ab3a01a6ae60d0835ad0e04ccf0' }, node: { _id: 'fb59038dcec33ab3a01a6ae60d0835ad0e04ccf0', - alternativeNames: [ - 'photos.amazon.co.uk', - 'photos.amazon.de', - 'photos.amazon.es', - 'photos.amazon.eu', - 'photos.amazon.fr', - 'photos.amazon.it', - ], - commonNames: ['photos.amazon.eu'], - issuerNames: ['Amazon'], + subjects: ['photos.amazon.eu'], + issuers: ['Amazon'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-04-23T12:00:00.000Z'], }, @@ -180,20 +126,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'f9815293c883a6006f0b2d95a4895bdc501fd174' }, node: { _id: 'f9815293c883a6006f0b2d95a4895bdc501fd174', - alternativeNames: [ - '*.api.cdn.hbo.com', - '*.artist.cdn.hbo.com', - '*.cdn.hbo.com', - '*.lv3.cdn.hbo.com', - 'artist.api.cdn.hbo.com', - 'artist.api.lv3.cdn.hbo.com', - 'artist.staging.cdn.hbo.com', - 'artist.staging.hurley.lv3.cdn.hbo.com', - 'atv.api.lv3.cdn.hbo.com', - 'atv.staging.hurley.lv3.cdn.hbo.com', - ], - commonNames: ['cdn.hbo.com'], - issuerNames: ['Sectigo RSA Organization Validation Secure Server CA'], + subjects: ['cdn.hbo.com'], + issuers: ['Sectigo RSA Organization Validation Secure Server CA'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2021-02-10T23:59:59.000Z'], }, @@ -202,9 +136,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'f8db6a69797e383dca2529727369595733123386' }, node: { _id: 'f8db6a69797e383dca2529727369595733123386', - alternativeNames: ['www.google.com'], - commonNames: ['www.google.com'], - issuerNames: ['GTS CA 1O1'], + subjects: ['www.google.com'], + issuers: ['GTS CA 1O1'], ja3: ['a111d93cdf31f993c40a8a9ef13e8d7e'], notAfter: ['2019-12-10T13:32:54.000Z'], }, @@ -226,7 +159,7 @@ export const mockRequest = { timerange: { interval: '12h', from: 1570716261267, to: 1570802661267 }, }, query: - 'query GetTlsQuery($sourceId: ID!, $filterQuery: String, $flowTarget: FlowTarget!, $ip: String!, $pagination: PaginationInputPaginated!, $sort: TlsSortField!, $timerange: TimerangeInput!, $defaultIndex: [String!]!, $inspect: Boolean!) {\n source(id: $sourceId) {\n id\n Tls(filterQuery: $filterQuery, flowTarget: $flowTarget, ip: $ip, pagination: $pagination, sort: $sort, timerange: $timerange, defaultIndex: $defaultIndex) {\n totalCount\n edges {\n node {\n _id\n alternativeNames\n commonNames\n ja3\n issuerNames\n notAfter\n __typename\n }\n cursor {\n value\n __typename\n }\n __typename\n }\n pageInfo {\n activePage\n fakeTotalCount\n showMorePagesIndicator\n __typename\n }\n inspect @include(if: $inspect) {\n dsl\n response\n __typename\n }\n __typename\n }\n __typename\n }\n}\n', + 'query GetTlsQuery($sourceId: ID!, $filterQuery: String, $flowTarget: FlowTarget!, $ip: String!, $pagination: PaginationInputPaginated!, $sort: TlsSortField!, $timerange: TimerangeInput!, $defaultIndex: [String!]!, $inspect: Boolean!) {\n source(id: $sourceId) {\n id\n Tls(filterQuery: $filterQuery, flowTarget: $flowTarget, ip: $ip, pagination: $pagination, sort: $sort, timerange: $timerange, defaultIndex: $defaultIndex) {\n totalCount\n edges {\n node {\n _id\n subjects\n ja3\n issuers\n notAfter\n __typename\n }\n cursor {\n value\n __typename\n }\n __typename\n }\n pageInfo {\n activePage\n fakeTotalCount\n showMorePagesIndicator\n __typename\n }\n inspect @include(if: $inspect) {\n dsl\n response\n __typename\n }\n __typename\n }\n __typename\n }\n}\n', }, }; @@ -250,28 +183,16 @@ export const mockResponse = { { key: 1572177600000, key_as_string: '2019-10-27T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Secure Server CA', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: '*.1.nflxso.net', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.1.nflxso.net', doc_count: 1 }, - { key: '*.a.nflxso.net', doc_count: 1 }, - { key: 'assets.nflxext.com', doc_count: 1 }, - { key: 'cast.netflix.com', doc_count: 1 }, - { key: 'codex.nflxext.com', doc_count: 1 }, - { key: 'tvui.netflix.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -288,24 +209,16 @@ export const mockResponse = { { key: 1580558400000, key_as_string: '2020-02-01T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'Amazon', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'cogocast.net', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.cogocast.net', doc_count: 1 }, - { key: 'cogocast.net', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -322,26 +235,18 @@ export const mockResponse = { { key: 1583531829000, key_as_string: '2020-03-06T21:57:09.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'Trustwave Organization Validation SHA256 CA, Level 1', doc_count: 1 }, ], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'player-devintever2.mountain.siriusxm.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'player-devintever2-imperva.mountain.siriusxm.com', doc_count: 1 }, - { key: 'player-devintever2.mountain.siriusxm.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -358,27 +263,16 @@ export const mockResponse = { { key: 1593864000000, key_as_string: '2020-07-04T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Extended Validation Server CA', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'appleid.apple.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'appleid-nc-s.apple.com', doc_count: 1 }, - { key: 'appleid-nwk-s.apple.com', doc_count: 1 }, - { key: 'appleid-prn-s.apple.com', doc_count: 1 }, - { key: 'appleid-rno-s.apple.com', doc_count: 1 }, - { key: 'appleid.apple.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -395,32 +289,16 @@ export const mockResponse = { { key: 1585051200000, key_as_string: '2020-03-24T12:00:00.000Z', doc_count: 2 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Extended Validation Server CA', doc_count: 2 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'itunes.apple.com', doc_count: 2 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 156, - buckets: [ - { key: 'api.itunes.apple.com', doc_count: 2 }, - { key: 'appsto.re', doc_count: 2 }, - { key: 'ax.init.itunes.apple.com', doc_count: 2 }, - { key: 'bag.itunes.apple.com', doc_count: 2 }, - { key: 'bookkeeper.itunes.apple.com', doc_count: 2 }, - { key: 'c.itunes.apple.com', doc_count: 2 }, - { key: 'carrierbundle.itunes.apple.com', doc_count: 2 }, - { key: 'client-api.itunes.apple.com', doc_count: 2 }, - { key: 'cma.itunes.apple.com', doc_count: 2 }, - { key: 'courses.apple.com', doc_count: 2 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -437,32 +315,16 @@ export const mockResponse = { { key: 1586009106000, key_as_string: '2020-04-04T14:05:06.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'GlobalSign CloudSSL CA - SHA256 - G3', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'incapsula.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 110, - buckets: [ - { key: '*.adlercasino.com', doc_count: 1 }, - { key: '*.allaustraliancasino.com', doc_count: 1 }, - { key: '*.alletf.com', doc_count: 1 }, - { key: '*.appareldesignpartners.com', doc_count: 1 }, - { key: '*.atmosfir.net', doc_count: 1 }, - { key: '*.cityofboston.gov', doc_count: 1 }, - { key: '*.cp.mytoyotaentune.com', doc_count: 1 }, - { key: '*.decathlon.be', doc_count: 1 }, - { key: '*.decathlon.co.uk', doc_count: 1 }, - { key: '*.decathlon.de', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -479,24 +341,16 @@ export const mockResponse = { { key: 1635336000000, key_as_string: '2021-10-27T12:00:00.000Z', doc_count: 325 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert Baltimore CA-2 G2', doc_count: 325 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: '*.siriusxm.com', doc_count: 325 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.siriusxm.com', doc_count: 325 }, - { key: 'siriusxm.com', doc_count: 325 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -516,28 +370,16 @@ export const mockResponse = { { key: 1587643200000, key_as_string: '2020-04-23T12:00:00.000Z', doc_count: 5 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'Amazon', doc_count: 5 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'photos.amazon.eu', doc_count: 5 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'photos.amazon.co.uk', doc_count: 5 }, - { key: 'photos.amazon.de', doc_count: 5 }, - { key: 'photos.amazon.es', doc_count: 5 }, - { key: 'photos.amazon.eu', doc_count: 5 }, - { key: 'photos.amazon.fr', doc_count: 5 }, - { key: 'photos.amazon.it', doc_count: 5 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -554,34 +396,18 @@ export const mockResponse = { { key: 1613001599000, key_as_string: '2021-02-10T23:59:59.000Z', doc_count: 29 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'Sectigo RSA Organization Validation Secure Server CA', doc_count: 29 }, ], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'cdn.hbo.com', doc_count: 29 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 29, - buckets: [ - { key: '*.api.cdn.hbo.com', doc_count: 29 }, - { key: '*.artist.cdn.hbo.com', doc_count: 29 }, - { key: '*.cdn.hbo.com', doc_count: 29 }, - { key: '*.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.api.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.api.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.staging.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.staging.hurley.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'atv.api.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'atv.staging.hurley.lv3.cdn.hbo.com', doc_count: 29 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -598,17 +424,12 @@ export const mockResponse = { { key: 1575984774000, key_as_string: '2019-12-10T13:32:54.000Z', doc_count: 5 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'GTS CA 1O1', doc_count: 5 }], }, - common_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'www.google.com', doc_count: 5 }], - }, - alternative_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'www.google.com', doc_count: 5 }], @@ -643,10 +464,9 @@ export const mockOptions = { fields: [ 'totalCount', '_id', - 'alternativeNames', - 'commonNames', + 'subjects', 'ja3', - 'issuerNames', + 'issuers', 'notAfter', 'edges.cursor.value', 'pageInfo.activePage', diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts index 2ff33a800fcd5..bc65be642dabc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts @@ -12,41 +12,36 @@ import { TlsSortField, Direction, TlsFields } from '../../graphql/types'; const getAggs = (querySize: number, sort: TlsSortField) => ({ count: { cardinality: { - field: 'tls.server_certificate.fingerprint.sha1', + field: 'tls.server.hash.sha1', }, }, sha1: { terms: { - field: 'tls.server_certificate.fingerprint.sha1', + field: 'tls.server.hash.sha1', size: querySize, order: { ...getQueryOrder(sort), }, }, aggs: { - issuer_names: { + issuers: { terms: { - field: 'tls.server_certificate.issuer.common_name', + field: 'tls.server.issuer', }, }, - common_names: { + subjects: { terms: { - field: 'tls.server_certificate.subject.common_name', - }, - }, - alternative_names: { - terms: { - field: 'tls.server_certificate.alternative_names', + field: 'tls.server.subject', }, }, not_after: { terms: { - field: 'tls.server_certificate.not_after', + field: 'tls.server.not_after', }, }, ja3: { terms: { - field: 'tls.fingerprints.ja3.hash', + field: 'tls.server.ja3s', }, }, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/types.ts b/x-pack/legacy/plugins/siem/server/lib/tls/types.ts index bac5426f72e08..1fbb31ba3e0f3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/types.ts @@ -18,11 +18,7 @@ export interface TlsBuckets { value_as_string: string; }; - alternative_names: { - buckets: Readonly>; - }; - - common_names: { + subjects: { buckets: Readonly>; }; @@ -30,7 +26,7 @@ export interface TlsBuckets { buckets: Readonly>; }; - issuer_names: { + issuers: { buckets: Readonly>; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts index dc38824989da3..24cae53d5d353 100644 --- a/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts @@ -191,6 +191,22 @@ export const buildQuery = ({ ], }, }, + { + bool: { + filter: [ + { + term: { + 'event.category': 'process', + }, + }, + { + term: { + 'event.type': 'start', + }, + }, + ], + }, + }, ], minimum_should_match: 1, filter, diff --git a/x-pack/test/api_integration/apis/siem/tls.ts b/x-pack/test/api_integration/apis/siem/tls.ts index 949ed530e9b27..8467308d709af 100644 --- a/x-pack/test/api_integration/apis/siem/tls.ts +++ b/x-pack/test/api_integration/apis/siem/tls.ts @@ -16,17 +16,16 @@ import { FtrProviderContext } from '../../ftr_provider_context'; const FROM = new Date('2000-01-01T00:00:00.000Z').valueOf(); const TO = new Date('3000-01-01T00:00:00.000Z').valueOf(); -const SOURCE_IP = '157.230.208.30'; -const DESTINATION_IP = '91.189.92.20'; +const SOURCE_IP = '10.128.0.35'; +const DESTINATION_IP = '74.125.129.95'; const expectedResult = { __typename: 'TlsNode', - _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], - ja3: ['839868ad711dc55bde0d37a87f14740d'], - notAfter: ['2019-05-22T12:00:00.000Z'], + _id: '16989191B1A93ECECD5FE9E63EBD4B5C3B606D26', + subjects: ['CN=edgecert.googleapis.com,O=Google LLC,L=Mountain View,ST=California,C=US'], + issuers: ['CN=GTS CA 1O1,O=Google Trust Services,C=US'], + ja3: [], + notAfter: ['2020-05-06T11:52:15.000Z'], }; const expectedOverviewDestinationResult = { @@ -36,27 +35,29 @@ const expectedOverviewDestinationResult = { __typename: 'TlsEdges', cursor: { __typename: 'CursorType', - value: '61749734b3246f1584029deb4f5276c64da00ada', + value: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', }, node: { __typename: 'TlsNode', - _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], - ja3: ['839868ad711dc55bde0d37a87f14740d'], - notAfter: ['2019-05-22T12:00:00.000Z'], + _id: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', + subjects: [ + 'CN=*.cdn.mozilla.net,OU=Cloud Services,O=Mozilla Corporation,L=Mountain View,ST=California,C=US', + ], + issuers: ['CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US'], + ja3: [], + notAfter: ['2020-12-09T12:00:00.000Z'], }, }, ], pageInfo: { __typename: 'PageInfoPaginated', activePage: 0, - fakeTotalCount: 1, + fakeTotalCount: 3, showMorePagesIndicator: false, }, - totalCount: 1, + totalCount: 3, }; + const expectedOverviewSourceResult = { __typename: 'TlsData', edges: [ @@ -64,26 +65,27 @@ const expectedOverviewSourceResult = { __typename: 'TlsEdges', cursor: { __typename: 'CursorType', - value: '61749734b3246f1584029deb4f5276c64da00ada', + value: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', }, node: { __typename: 'TlsNode', - _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], - ja3: ['839868ad711dc55bde0d37a87f14740d'], - notAfter: ['2019-05-22T12:00:00.000Z'], + _id: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', + subjects: [ + 'CN=*.cdn.mozilla.net,OU=Cloud Services,O=Mozilla Corporation,L=Mountain View,ST=California,C=US', + ], + issuers: ['CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US'], + ja3: [], + notAfter: ['2020-12-09T12:00:00.000Z'], }, }, ], pageInfo: { __typename: 'PageInfoPaginated', activePage: 0, - fakeTotalCount: 1, + fakeTotalCount: 3, showMorePagesIndicator: false, }, - totalCount: 1, + totalCount: 3, }; export default function({ getService }: FtrProviderContext) { @@ -91,8 +93,8 @@ export default function({ getService }: FtrProviderContext) { const client = getService('siemGraphQLClient'); describe('Tls Test with Packetbeat', () => { describe('Tls Test', () => { - before(() => esArchiver.load('packetbeat/default')); - after(() => esArchiver.unload('packetbeat/default')); + before(() => esArchiver.load('packetbeat/tls')); + after(() => esArchiver.unload('packetbeat/tls')); it('Ensure data is returned for FlowTarget.Source', () => { return client @@ -160,8 +162,8 @@ export default function({ getService }: FtrProviderContext) { }); describe('Tls Overview Test', () => { - before(() => esArchiver.load('packetbeat/default')); - after(() => esArchiver.unload('packetbeat/default')); + before(() => esArchiver.load('packetbeat/tls')); + after(() => esArchiver.unload('packetbeat/tls')); it('Ensure data is returned for FlowTarget.Source', () => { return client @@ -189,7 +191,8 @@ export default function({ getService }: FtrProviderContext) { }) .then(resp => { const tls = resp.data.source.Tls; - expect(tls).to.eql(expectedOverviewSourceResult); + expect(tls.pageInfo).to.eql(expectedOverviewSourceResult.pageInfo); + expect(tls.edges[0]).to.eql(expectedOverviewSourceResult.edges[0]); }); }); @@ -219,7 +222,8 @@ export default function({ getService }: FtrProviderContext) { }) .then(resp => { const tls = resp.data.source.Tls; - expect(tls).to.eql(expectedOverviewDestinationResult); + expect(tls.pageInfo).to.eql(expectedOverviewDestinationResult.pageInfo); + expect(tls.edges[0]).to.eql(expectedOverviewDestinationResult.edges[0]); }); }); }); diff --git a/x-pack/test/functional/es_archives/packetbeat/tls/data.json.gz b/x-pack/test/functional/es_archives/packetbeat/tls/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..cf7a5e5f0d4467ffe4a051ee92be733018ff792c GIT binary patch literal 3929 zcmV-f52o-RiwFox#dux-17u-zVJ>QOZ*BnXU29X@$eRA1U*Y1@+1=RN_e-i$b-<7? zH6+XgX3k``rd+L7%jh6W9!VyFQ~B@PEn|=+*_I(dNJJ4QsNcT5_wMHv`Ok}9ZzsQ; znw?j@9Y4`KFWrL+70uj}|JWbwkD>2cp*h`4gqHJQ4RT*toa#ZCc#h2bmA z1`LotulrkiH8ycx8o)j>(=#(MsftGPH46m*LO$(lUxGgI0@fFXz`l^i2v1-EIlCFq z_CHIqJXVu}8)>4?ZH`fuDgt2)!{uHJ?%uMd+?CXe|RwZaI!o4*H5R1quqm(k)3$-cKB{|a=44=Qm}3ajP~ij z6)sE!0gn(Hm_0$U36NAC=h((>=mAU+T2`bv*GCJDY{6#NX*P8nF#c%13AeNVD-rbO zds@4R8>->quKh+}^vm(lM~DGkL+eJgO^Xqi4gQ8|&P)5*ikp>g?L@G8u5GqcnQ{BB zl|8m)&4;^mYM5)dH+0>$lEKD4H34kbW5c?(onhPYw;B6Ru45F zRhn6sQS%i=JHnM+7f(|Y+dM4~i(G3I#zE3VHFvm+#Wzt<*z}aprl&|WK7~7#hksS6 zcGPqlIX=3zM(WbK)kUBjmA28m|1;Dy>sg*p{}&Eg%x&~&nuKwlja=HG^14ed$Y!yP z`cs?!SxIajv=kgH^Zo0+HIKY9aj(e#T(3^>|md)Ep2iVa$i-h+cimp5-}6ve!$aRc)vP+Q^B&tH~1-Wc9I z{RaU-5Gm;+tbG#%!oa{lk1?Oy1Fn$FaIA>sk)hhB08lLg&j??58X1miSxPBMymmQm zu8GOd62FNpJMVuxI$GX9g~8=h54PMZH7;#!;kQbWtsbsal-qNgb!Zo+aBz)F`tP-Q za6z)x-BmXbX4$NA##szw4`Y@+i#&Wk9EU@@{GQ7IdnZO)*JuB7+v*Lg2}03*7U%Q( zjNeY`%}F{|ad@?G-?zj66ziR}Iua-ONCo-)pi*2#K-35F6rxuE*zW-VOBWh9ojemH z^P2z!pme<;I4!!Y>NTBtHhhlG&E>6=cl~YZvxYPauiPxocx&pNhRTNC*~D8cp9_d7 zgaS!O1cQJuZWjk2#ZpMl8RE4(q$%{c!RD!XzhQCN%xk!UYm#ys^;$33DACH@Ub^;o zj?^BPd%uU~(`FrbH=njQOj2v7^T%sDSzfy9g5u)B-K7@#7;2N%yA{`n^Q+RGpR?nmvTVq- zBCAe~sgUbjWIk(?Ge{}-fRvaLtQ2615(?5qXGySA-*NS(prkI zf@)sdI+H5VEfUklI|hedTBFX+imQV>os}>4?S^NQ#w-7ZJ70I|RoPix4T}SYUB|&5 zIS_DvpARYB+m#$rM$y6W4P}JC*%N~E14?(ro7J1Ti3K~u_pjHR@5|%YW#fA}d|k2F zJ0wVVNsme%$t}8;IJ`Xm?e%g{wkvOl@Gp;EFQo}DPflOo2I%?r8U?%rvn}0)U$=~^ zpumgDAUD_lH;v9AeR=Wa#S16twpje^>9Kf%IxN0eNlSpQUiM+56yMUfUWcEJ4#iVnE1_V0lEhXU>mU=%A(aNtuaDD=2t(kKOh7a&d?SnnPx z5@;VW1}O8i7ho0Wz@wB^{pxoD+;2>bZzf#t_`W{~AN4_T+x#3Md@P`Y`3Qgjar-4C z5CS4<|0M|GRM-PlU7;Tm!EdK$9gM%;%sUvrftL-o+rju9j9)%OHksAa9{C>n;Pq68 zs|N@C9~F|1tu`znwo6CauZ-9edSE@!F5v{qeFOlJRD!*OJ$5j}co!1J_x6WxAY8rY z2Sw~R=R;owv2RKgejM1o|KzZ}1hRweiKY#sv ze)LQ7S&qe%L=s>5zR!@5IPfJ<5P^UOTrlk!UjVKGL_h!#<%Ws? zLDs!)-@R^M?(_{}_1)|C9Zuij^c_y$;q*Vy>-HVG-{y7xAV8WbAPrH#W`F_5Mrnz> z0C^ZIj*Rg+Q_L5VDFg%&3MmgOYjheCgKI(a`>h@5-uC~I-}Zke=4AuN$G>IgZ=+v6 zpS}O}QOgFzfU)jv|M^OmaU0x-0U<_N!)RUEz@wB6M9Fv@#^b>_Nyd>;(=cm(0hbdi zL~8%Y=UgfPB@-BX4be zz(zjzT9*Xe17rUhp!FT_{5#?5_d@dTQ9;leGQT%4e;4?FTj+efq_)(DhXUp+ys5$S z>xTbMv3!UjZ$zy&1NH4LexUenFMHYp?kl|V<~ds9=id&3-5$wrgLSt@`?o;F+g1eJ zBIK*bdN%m^7K?0yfpISAp(XO?n)>Rj8PfvQo-5xxyJDzPT zEZ!FD>z$^vEWZWH*ZQdXRX(8ZvIoLf6upA9RjGyRh58V!@3ji9Epqo3#TJy+{A&fP zg_v>*0AZA1K{1wqyDL#bDW+6N!kLgjR(=0N7F)D-a1U2UGy65BB*U81h=->qTi!bLO>#!lvLVeg$jTf zkT!?G*18Z84Jqfp+F83ogTjvQ z{a*3RT?~pi&vvOP~M%xznae literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/es_archives/packetbeat/tls/mappings.json b/x-pack/test/functional/es_archives/packetbeat/tls/mappings.json new file mode 100644 index 0000000000000..2b5ed05c7e8a9 --- /dev/null +++ b/x-pack/test/functional/es_archives/packetbeat/tls/mappings.json @@ -0,0 +1,9583 @@ +{ + "type": "index", + "value": { + "aliases": { + "packetbeat-7.6.0": { + "is_write_index": false + }, + "packetbeat-tls": { + "filter": { + "term": { + "event.dataset": "tls" + } + } + } + }, + "index": "packetbeat-7.6.0-2020.03.03-000001", + "mappings": { + "_meta": { + "beat": "packetbeat", + "version": "7.6.0" + }, + "date_detection": false, + "dynamic_templates": [ + { + "labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "labels.*" + } + }, + { + "container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "container.labels.*" + } + }, + { + "dns.answers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "dns.answers.*" + } + }, + { + "log.syslog": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "log.syslog.*" + } + }, + { + "fields": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "fields.*" + } + }, + { + "docker.container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "docker.container.labels.*" + } + }, + { + "kubernetes.labels.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.labels.*" + } + }, + { + "kubernetes.annotations.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.annotations.*" + } + }, + { + "amqp.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "amqp.headers.*" + } + }, + { + "cassandra.response.supported": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "cassandra.response.supported.*" + } + }, + { + "http.request.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.request.headers.*" + } + }, + { + "http.response.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.response.headers.*" + } + }, + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "amqp": { + "properties": { + "app-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "arguments": { + "type": "object" + }, + "auto-delete": { + "type": "boolean" + }, + "class-id": { + "type": "long" + }, + "consumer-count": { + "type": "long" + }, + "consumer-tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-encoding": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "correlation-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-tag": { + "type": "long" + }, + "durable": { + "type": "boolean" + }, + "exchange": { + "ignore_above": 1024, + "type": "keyword" + }, + "exchange-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "exclusive": { + "type": "boolean" + }, + "expiration": { + "ignore_above": 1024, + "type": "keyword" + }, + "headers": { + "type": "object" + }, + "if-empty": { + "type": "boolean" + }, + "if-unused": { + "type": "boolean" + }, + "immediate": { + "type": "boolean" + }, + "mandatory": { + "type": "boolean" + }, + "message-count": { + "type": "long" + }, + "message-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "method-id": { + "type": "long" + }, + "multiple": { + "type": "boolean" + }, + "no-ack": { + "type": "boolean" + }, + "no-local": { + "type": "boolean" + }, + "no-wait": { + "type": "boolean" + }, + "passive": { + "type": "boolean" + }, + "priority": { + "type": "long" + }, + "queue": { + "ignore_above": 1024, + "type": "keyword" + }, + "redelivered": { + "type": "boolean" + }, + "reply-code": { + "type": "long" + }, + "reply-text": { + "ignore_above": 1024, + "type": "keyword" + }, + "reply-to": { + "ignore_above": 1024, + "type": "keyword" + }, + "routing-key": { + "ignore_above": 1024, + "type": "keyword" + }, + "timestamp": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "user-id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes_in": { + "path": "source.bytes", + "type": "alias" + }, + "bytes_out": { + "path": "destination.bytes", + "type": "alias" + }, + "cassandra": { + "properties": { + "no_request": { + "type": "boolean" + }, + "request": { + "properties": { + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "authentication": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "type": "long" + }, + "details": { + "properties": { + "alive": { + "type": "long" + }, + "arg_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "blockfor": { + "type": "long" + }, + "data_present": { + "type": "boolean" + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_failures": { + "ignore_above": 1024, + "type": "keyword" + }, + "read_consistency": { + "ignore_above": 1024, + "type": "keyword" + }, + "received": { + "type": "long" + }, + "required": { + "type": "long" + }, + "stmt_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "write_type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "host": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "result": { + "properties": { + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "prepared": { + "properties": { + "prepared_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "req_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resp_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "rows": { + "properties": { + "meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "num_rows": { + "type": "long" + } + } + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "supported": { + "type": "object" + }, + "warnings": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dhcpv4": { + "properties": { + "assigned_ip": { + "type": "ip" + }, + "client_ip": { + "type": "ip" + }, + "client_mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "hardware_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "hops": { + "type": "long" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "option": { + "properties": { + "boot_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "broadcast_address": { + "type": "ip" + }, + "class_identifier": { + "ignore_above": 1024, + "type": "keyword" + }, + "dns_servers": { + "type": "ip" + }, + "domain_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip_address_lease_time_sec": { + "type": "long" + }, + "max_dhcp_message_size": { + "type": "long" + }, + "message": { + "norms": false, + "type": "text" + }, + "message_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "ntp_servers": { + "type": "ip" + }, + "parameter_request_list": { + "ignore_above": 1024, + "type": "keyword" + }, + "rebinding_time_sec": { + "type": "long" + }, + "renewal_time_sec": { + "type": "long" + }, + "requested_ip_address": { + "type": "ip" + }, + "router": { + "type": "ip" + }, + "server_identifier": { + "type": "ip" + }, + "subnet_mask": { + "type": "ip" + }, + "time_servers": { + "type": "ip" + }, + "utc_time_offset_sec": { + "type": "long" + }, + "vendor_identifying_options": { + "type": "object" + } + } + }, + "relay_ip": { + "type": "ip" + }, + "seconds": { + "type": "long" + }, + "server_ip": { + "type": "ip" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "transaction_id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "dns": { + "properties": { + "additionals": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "additionals_count": { + "type": "long" + }, + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "answers_count": { + "type": "long" + }, + "authorities": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "authorities_count": { + "type": "long" + }, + "flags": { + "properties": { + "authentic_data": { + "type": "boolean" + }, + "authoritative": { + "type": "boolean" + }, + "checking_disabled": { + "type": "boolean" + }, + "recursion_available": { + "type": "boolean" + }, + "recursion_desired": { + "type": "boolean" + }, + "truncated_response": { + "type": "boolean" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "opt": { + "properties": { + "cookie": { + "ignore_above": 1024, + "type": "keyword" + }, + "do": { + "type": "boolean" + }, + "ext_rcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "udp_size": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "etld_plus_one": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "docker": { + "properties": { + "container": { + "properties": { + "labels": { + "type": "object" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fields": { + "properties": { + "labels": { + "properties": { + "responsible_human": { + "type": "keyword" + } + } + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "flow": { + "properties": { + "final": { + "type": "boolean" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "type": "long" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "containerized": { + "type": "boolean" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + } + } + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + } + } + }, + "status_code": { + "type": "long" + }, + "status_phrase": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "icmp": { + "properties": { + "request": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "response": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "jolokia": { + "properties": { + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "secured": { + "type": "boolean" + }, + "server": { + "properties": { + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "annotations": { + "properties": { + "*": { + "type": "object" + } + } + }, + "container": { + "properties": { + "image": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "deployment": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "properties": { + "*": { + "type": "object" + } + } + }, + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "replicaset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "statefulset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "memcache": { + "properties": { + "protocol_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "request": { + "properties": { + "automove": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "delta": { + "type": "long" + }, + "dest_class": { + "type": "long" + }, + "exptime": { + "type": "long" + }, + "flags": { + "type": "long" + }, + "initial": { + "type": "long" + }, + "line": { + "ignore_above": 1024, + "type": "keyword" + }, + "noreply": { + "type": "boolean" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "quiet": { + "type": "boolean" + }, + "raw_args": { + "ignore_above": 1024, + "type": "keyword" + }, + "sleep_us": { + "type": "long" + }, + "source_class": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vbucket": { + "type": "long" + }, + "verbosity": { + "type": "long" + } + } + }, + "response": { + "properties": { + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "error_msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "type": "long" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "status_code": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "mongodb": { + "properties": { + "cursorId": { + "ignore_above": 1024, + "type": "keyword" + }, + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "fullCollectionName": { + "ignore_above": 1024, + "type": "keyword" + }, + "numberReturned": { + "type": "long" + }, + "numberToReturn": { + "type": "long" + }, + "numberToSkip": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "returnFieldsSelector": { + "ignore_above": 1024, + "type": "keyword" + }, + "selector": { + "ignore_above": 1024, + "type": "keyword" + }, + "startingFrom": { + "ignore_above": 1024, + "type": "keyword" + }, + "update": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "mysql": { + "properties": { + "affected_rows": { + "type": "long" + }, + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "insert_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "nfs": { + "properties": { + "minor_version": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "notes": { + "path": "error.message", + "type": "alias" + }, + "observer": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "params": { + "norms": false, + "type": "text" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pgsql": { + "properties": { + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "error_severity": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "redis": { + "properties": { + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "request": { + "norms": false, + "type": "text" + }, + "resource": { + "ignore_above": 1024, + "type": "keyword" + }, + "response": { + "norms": false, + "type": "text" + }, + "rpc": { + "properties": { + "auth_flavor": { + "ignore_above": 1024, + "type": "keyword" + }, + "cred": { + "properties": { + "gid": { + "type": "long" + }, + "gids": { + "ignore_above": 1024, + "type": "keyword" + }, + "machinename": { + "ignore_above": 1024, + "type": "keyword" + }, + "stamp": { + "type": "long" + }, + "uid": { + "type": "long" + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "xid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "thrift": { + "properties": { + "exceptions": { + "ignore_above": 1024, + "type": "keyword" + }, + "params": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + }, + "service": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "timeseries": { + "properties": { + "instance": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tls": { + "properties": { + "alert_types": { + "path": "tls.detailed.alert_types", + "type": "alias" + }, + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "client_certificate": { + "properties": { + "alternative_names": { + "path": "tls.detailed.client_certificate.alternative_names", + "type": "alias" + }, + "issuer": { + "properties": { + "common_name": { + "path": "tls.detailed.client_certificate.issuer.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.client_certificate.issuer.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.client_certificate.issuer.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.client_certificate.issuer.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.client_certificate.issuer.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.client_certificate.issuer.province", + "type": "alias" + } + } + }, + "not_after": { + "path": "tls.detailed.client_certificate.not_after", + "type": "alias" + }, + "not_before": { + "path": "tls.detailed.client_certificate.not_before", + "type": "alias" + }, + "public_key_algorithm": { + "path": "tls.detailed.client_certificate.public_key_algorithm", + "type": "alias" + }, + "public_key_size": { + "path": "tls.detailed.client_certificate.public_key_size", + "type": "alias" + }, + "serial_number": { + "path": "tls.detailed.client_certificate.serial_number", + "type": "alias" + }, + "signature_algorithm": { + "path": "tls.detailed.client_certificate.signature_algorithm", + "type": "alias" + }, + "subject": { + "properties": { + "common_name": { + "path": "tls.detailed.client_certificate.subject.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.client_certificate.subject.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.client_certificate.subject.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.client_certificate.subject.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.client_certificate.subject.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.client_certificate.subject.province", + "type": "alias" + } + } + }, + "version": { + "path": "tls.detailed.client_certificate.version", + "type": "alias" + } + } + }, + "client_certificate_requested": { + "path": "tls.detailed.client_certificate_requested", + "type": "alias" + }, + "client_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "path": "tls.detailed.client_hello.extensions._unparsed_", + "type": "alias" + }, + "application_layer_protocol_negotiation": { + "path": "tls.detailed.client_hello.extensions.application_layer_protocol_negotiation", + "type": "alias" + }, + "ec_points_formats": { + "path": "tls.detailed.client_hello.extensions.ec_points_formats", + "type": "alias" + }, + "server_name_indication": { + "path": "tls.detailed.client_hello.extensions.server_name_indication", + "type": "alias" + }, + "session_ticket": { + "path": "tls.detailed.client_hello.extensions.session_ticket", + "type": "alias" + }, + "signature_algorithms": { + "path": "tls.detailed.client_hello.extensions.signature_algorithms", + "type": "alias" + }, + "supported_groups": { + "path": "tls.detailed.client_hello.extensions.supported_groups", + "type": "alias" + }, + "supported_versions": { + "path": "tls.detailed.client_hello.extensions.supported_versions", + "type": "alias" + } + } + }, + "session_id": { + "path": "tls.detailed.client_hello.session_id", + "type": "alias" + }, + "supported_ciphers": { + "path": "tls.client.supported_ciphers", + "type": "alias" + }, + "supported_compression_methods": { + "path": "tls.detailed.client_hello.supported_compression_methods", + "type": "alias" + }, + "version": { + "path": "tls.detailed.client_hello.version", + "type": "alias" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "detailed": { + "properties": { + "alert_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "client_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "client_certificate_requested": { + "type": "boolean" + }, + "client_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_name_indication": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithms": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_groups": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_compression_methods": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resumption_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_certificate_chain": { + "properties": { + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_before": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "selected_compression_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "established": { + "type": "boolean" + }, + "fingerprints": { + "properties": { + "ja3": { + "path": "tls.client.ja3", + "type": "alias" + } + } + }, + "handshake_completed": { + "path": "tls.established", + "type": "alias" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "resumption_method": { + "path": "tls.detailed.resumption_method", + "type": "alias" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server_certificate": { + "properties": { + "alternative_names": { + "path": "tls.detailed.server_certificate.alternative_names", + "type": "alias" + }, + "issuer": { + "properties": { + "common_name": { + "path": "tls.detailed.server_certificate.issuer.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.server_certificate.issuer.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.server_certificate.issuer.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.server_certificate.issuer.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.server_certificate.issuer.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.server_certificate.issuer.province", + "type": "alias" + } + } + }, + "not_after": { + "path": "tls.detailed.server_certificate.not_after", + "type": "alias" + }, + "not_before": { + "path": "tls.detailed.server_certificate.not_before", + "type": "alias" + }, + "public_key_algorithm": { + "path": "tls.detailed.server_certificate.public_key_algorithm", + "type": "alias" + }, + "public_key_size": { + "path": "tls.detailed.server_certificate.public_key_size", + "type": "alias" + }, + "serial_number": { + "path": "tls.detailed.server_certificate.serial_number", + "type": "alias" + }, + "signature_algorithm": { + "path": "tls.detailed.server_certificate.signature_algorithm", + "type": "alias" + }, + "subject": { + "properties": { + "common_name": { + "path": "tls.detailed.server_certificate.subject.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.server_certificate.subject.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.server_certificate.subject.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.server_certificate.subject.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.server_certificate.subject.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.server_certificate.subject.province", + "type": "alias" + } + } + }, + "version": { + "path": "tls.detailed.server_certificate.version", + "type": "alias" + } + } + }, + "server_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "path": "tls.detailed.server_hello.extensions._unparsed_", + "type": "alias" + }, + "application_layer_protocol_negotiation": { + "path": "tls.detailed.server_hello.extensions.application_layer_protocol_negotiation", + "type": "alias" + }, + "ec_points_formats": { + "path": "tls.detailed.server_hello.extensions.ec_points_formats", + "type": "alias" + }, + "session_ticket": { + "path": "tls.detailed.server_hello.extensions.session_ticket", + "type": "alias" + }, + "supported_versions": { + "path": "tls.detailed.server_hello.extensions.supported_versions", + "type": "alias" + } + } + }, + "selected_cipher": { + "path": "tls.cipher", + "type": "alias" + }, + "selected_compression_method": { + "path": "tls.detailed.server_hello.selected_compression_method", + "type": "alias" + }, + "session_id": { + "path": "tls.detailed.server_hello.session_id", + "type": "alias" + }, + "version": { + "path": "tls.detailed.server_hello.version", + "type": "alias" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tracing": { + "properties": { + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "indexing_complete": "true", + "name": "packetbeat", + "rollover_alias": "packetbeat-7.6.0" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "query": { + "default_field": [ + "message", + "tags", + "agent.ephemeral_id", + "agent.id", + "agent.name", + "agent.type", + "agent.version", + "as.organization.name", + "client.address", + "client.as.organization.name", + "client.domain", + "client.geo.city_name", + "client.geo.continent_name", + "client.geo.country_iso_code", + "client.geo.country_name", + "client.geo.name", + "client.geo.region_iso_code", + "client.geo.region_name", + "client.mac", + "client.registered_domain", + "client.top_level_domain", + "client.user.domain", + "client.user.email", + "client.user.full_name", + "client.user.group.domain", + "client.user.group.id", + "client.user.group.name", + "client.user.hash", + "client.user.id", + "client.user.name", + "cloud.account.id", + "cloud.availability_zone", + "cloud.instance.id", + "cloud.instance.name", + "cloud.machine.type", + "cloud.provider", + "cloud.region", + "container.id", + "container.image.name", + "container.image.tag", + "container.name", + "container.runtime", + "destination.address", + "destination.as.organization.name", + "destination.domain", + "destination.geo.city_name", + "destination.geo.continent_name", + "destination.geo.country_iso_code", + "destination.geo.country_name", + "destination.geo.name", + "destination.geo.region_iso_code", + "destination.geo.region_name", + "destination.mac", + "destination.registered_domain", + "destination.top_level_domain", + "destination.user.domain", + "destination.user.email", + "destination.user.full_name", + "destination.user.group.domain", + "destination.user.group.id", + "destination.user.group.name", + "destination.user.hash", + "destination.user.id", + "destination.user.name", + "dns.answers.class", + "dns.answers.data", + "dns.answers.name", + "dns.answers.type", + "dns.header_flags", + "dns.id", + "dns.op_code", + "dns.question.class", + "dns.question.name", + "dns.question.registered_domain", + "dns.question.subdomain", + "dns.question.top_level_domain", + "dns.question.type", + "dns.response_code", + "dns.type", + "ecs.version", + "error.code", + "error.id", + "error.message", + "error.stack_trace", + "error.type", + "event.action", + "event.category", + "event.code", + "event.dataset", + "event.hash", + "event.id", + "event.kind", + "event.module", + "event.original", + "event.outcome", + "event.provider", + "event.timezone", + "event.type", + "file.device", + "file.directory", + "file.extension", + "file.gid", + "file.group", + "file.hash.md5", + "file.hash.sha1", + "file.hash.sha256", + "file.hash.sha512", + "file.inode", + "file.mode", + "file.name", + "file.owner", + "file.path", + "file.target_path", + "file.type", + "file.uid", + "geo.city_name", + "geo.continent_name", + "geo.country_iso_code", + "geo.country_name", + "geo.name", + "geo.region_iso_code", + "geo.region_name", + "group.domain", + "group.id", + "group.name", + "hash.md5", + "hash.sha1", + "hash.sha256", + "hash.sha512", + "host.architecture", + "host.geo.city_name", + "host.geo.continent_name", + "host.geo.country_iso_code", + "host.geo.country_name", + "host.geo.name", + "host.geo.region_iso_code", + "host.geo.region_name", + "host.hostname", + "host.id", + "host.mac", + "host.name", + "host.os.family", + "host.os.full", + "host.os.kernel", + "host.os.name", + "host.os.platform", + "host.os.version", + "host.type", + "host.user.domain", + "host.user.email", + "host.user.full_name", + "host.user.group.domain", + "host.user.group.id", + "host.user.group.name", + "host.user.hash", + "host.user.id", + "host.user.name", + "http.request.body.content", + "http.request.method", + "http.request.referrer", + "http.response.body.content", + "http.version", + "log.level", + "log.logger", + "log.origin.file.name", + "log.origin.function", + "log.original", + "log.syslog.facility.name", + "log.syslog.severity.name", + "network.application", + "network.community_id", + "network.direction", + "network.iana_number", + "network.name", + "network.protocol", + "network.transport", + "network.type", + "observer.geo.city_name", + "observer.geo.continent_name", + "observer.geo.country_iso_code", + "observer.geo.country_name", + "observer.geo.name", + "observer.geo.region_iso_code", + "observer.geo.region_name", + "observer.hostname", + "observer.mac", + "observer.name", + "observer.os.family", + "observer.os.full", + "observer.os.kernel", + "observer.os.name", + "observer.os.platform", + "observer.os.version", + "observer.product", + "observer.serial_number", + "observer.type", + "observer.vendor", + "observer.version", + "organization.id", + "organization.name", + "os.family", + "os.full", + "os.kernel", + "os.name", + "os.platform", + "os.version", + "package.architecture", + "package.checksum", + "package.description", + "package.install_scope", + "package.license", + "package.name", + "package.path", + "package.version", + "process.args", + "text", + "process.executable", + "process.hash.md5", + "process.hash.sha1", + "process.hash.sha256", + "process.hash.sha512", + "process.name", + "text", + "text", + "text", + "text", + "text", + "process.thread.name", + "process.title", + "process.working_directory", + "server.address", + "server.as.organization.name", + "server.domain", + "server.geo.city_name", + "server.geo.continent_name", + "server.geo.country_iso_code", + "server.geo.country_name", + "server.geo.name", + "server.geo.region_iso_code", + "server.geo.region_name", + "server.mac", + "server.registered_domain", + "server.top_level_domain", + "server.user.domain", + "server.user.email", + "server.user.full_name", + "server.user.group.domain", + "server.user.group.id", + "server.user.group.name", + "server.user.hash", + "server.user.id", + "server.user.name", + "service.ephemeral_id", + "service.id", + "service.name", + "service.node.name", + "service.state", + "service.type", + "service.version", + "source.address", + "source.as.organization.name", + "source.domain", + "source.geo.city_name", + "source.geo.continent_name", + "source.geo.country_iso_code", + "source.geo.country_name", + "source.geo.name", + "source.geo.region_iso_code", + "source.geo.region_name", + "source.mac", + "source.registered_domain", + "source.top_level_domain", + "source.user.domain", + "source.user.email", + "source.user.full_name", + "source.user.group.domain", + "source.user.group.id", + "source.user.group.name", + "source.user.hash", + "source.user.id", + "source.user.name", + "threat.framework", + "threat.tactic.id", + "threat.tactic.name", + "threat.tactic.reference", + "threat.technique.id", + "threat.technique.name", + "threat.technique.reference", + "tracing.trace.id", + "tracing.transaction.id", + "url.domain", + "url.extension", + "url.fragment", + "url.full", + "url.original", + "url.password", + "url.path", + "url.query", + "url.registered_domain", + "url.scheme", + "url.top_level_domain", + "url.username", + "user.domain", + "user.email", + "user.full_name", + "user.group.domain", + "user.group.id", + "user.group.name", + "user.hash", + "user.id", + "user.name", + "user_agent.device.name", + "user_agent.name", + "text", + "user_agent.original", + "user_agent.os.family", + "user_agent.os.full", + "user_agent.os.kernel", + "user_agent.os.name", + "user_agent.os.platform", + "user_agent.os.version", + "user_agent.version", + "text", + "agent.hostname", + "timeseries.instance", + "cloud.project.id", + "cloud.image.id", + "host.os.build", + "host.os.codename", + "kubernetes.pod.name", + "kubernetes.pod.uid", + "kubernetes.namespace", + "kubernetes.node.name", + "kubernetes.replicaset.name", + "kubernetes.deployment.name", + "kubernetes.statefulset.name", + "kubernetes.container.name", + "kubernetes.container.image", + "jolokia.agent.version", + "jolokia.agent.id", + "jolokia.server.product", + "jolokia.server.version", + "jolokia.server.vendor", + "jolokia.url", + "type", + "server.process.name", + "server.process.args", + "server.process.executable", + "server.process.working_directory", + "server.process.start", + "client.process.name", + "client.process.args", + "client.process.executable", + "client.process.working_directory", + "client.process.start", + "flow.id", + "status", + "method", + "resource", + "path", + "query", + "params", + "request", + "response", + "amqp.reply-text", + "amqp.exchange", + "amqp.exchange-type", + "amqp.consumer-tag", + "amqp.routing-key", + "amqp.queue", + "amqp.content-type", + "amqp.content-encoding", + "amqp.delivery-mode", + "amqp.correlation-id", + "amqp.reply-to", + "amqp.expiration", + "amqp.message-id", + "amqp.timestamp", + "amqp.type", + "amqp.user-id", + "amqp.app-id", + "cassandra.request.headers.flags", + "cassandra.request.headers.stream", + "cassandra.request.headers.op", + "cassandra.request.query", + "cassandra.response.headers.flags", + "cassandra.response.headers.stream", + "cassandra.response.headers.op", + "cassandra.response.result.type", + "cassandra.response.result.rows.meta.keyspace", + "cassandra.response.result.rows.meta.table", + "cassandra.response.result.rows.meta.flags", + "cassandra.response.result.rows.meta.paging_state", + "cassandra.response.result.keyspace", + "cassandra.response.result.schema_change.change", + "cassandra.response.result.schema_change.keyspace", + "cassandra.response.result.schema_change.table", + "cassandra.response.result.schema_change.object", + "cassandra.response.result.schema_change.target", + "cassandra.response.result.schema_change.name", + "cassandra.response.result.schema_change.args", + "cassandra.response.result.prepared.prepared_id", + "cassandra.response.result.prepared.req_meta.keyspace", + "cassandra.response.result.prepared.req_meta.table", + "cassandra.response.result.prepared.req_meta.flags", + "cassandra.response.result.prepared.req_meta.paging_state", + "cassandra.response.result.prepared.resp_meta.keyspace", + "cassandra.response.result.prepared.resp_meta.table", + "cassandra.response.result.prepared.resp_meta.flags", + "cassandra.response.result.prepared.resp_meta.paging_state", + "cassandra.response.authentication.class", + "cassandra.response.warnings", + "cassandra.response.event.type", + "cassandra.response.event.change", + "cassandra.response.event.host", + "cassandra.response.event.schema_change.change", + "cassandra.response.event.schema_change.keyspace", + "cassandra.response.event.schema_change.table", + "cassandra.response.event.schema_change.object", + "cassandra.response.event.schema_change.target", + "cassandra.response.event.schema_change.name", + "cassandra.response.event.schema_change.args", + "cassandra.response.error.msg", + "cassandra.response.error.type", + "cassandra.response.error.details.read_consistency", + "cassandra.response.error.details.write_type", + "cassandra.response.error.details.keyspace", + "cassandra.response.error.details.table", + "cassandra.response.error.details.stmt_id", + "cassandra.response.error.details.num_failures", + "cassandra.response.error.details.function", + "cassandra.response.error.details.arg_types", + "dhcpv4.transaction_id", + "dhcpv4.flags", + "dhcpv4.client_mac", + "dhcpv4.server_name", + "dhcpv4.op_code", + "dhcpv4.hardware_type", + "dhcpv4.option.message_type", + "dhcpv4.option.parameter_request_list", + "dhcpv4.option.class_identifier", + "dhcpv4.option.domain_name", + "dhcpv4.option.hostname", + "dhcpv4.option.message", + "dhcpv4.option.boot_file_name", + "dns.question.etld_plus_one", + "dns.authorities.name", + "dns.authorities.type", + "dns.authorities.class", + "dns.additionals.name", + "dns.additionals.type", + "dns.additionals.class", + "dns.additionals.data", + "dns.opt.version", + "dns.opt.ext_rcode", + "http.response.status_phrase", + "icmp.version", + "icmp.request.message", + "icmp.response.message", + "memcache.protocol_type", + "memcache.request.line", + "memcache.request.command", + "memcache.response.command", + "memcache.request.type", + "memcache.response.type", + "memcache.response.error_msg", + "memcache.request.opcode", + "memcache.response.opcode", + "memcache.response.status", + "memcache.request.raw_args", + "memcache.request.automove", + "memcache.response.version", + "mongodb.error", + "mongodb.fullCollectionName", + "mongodb.startingFrom", + "mongodb.query", + "mongodb.returnFieldsSelector", + "mongodb.selector", + "mongodb.update", + "mongodb.cursorId", + "mysql.insert_id", + "mysql.num_fields", + "mysql.num_rows", + "mysql.query", + "mysql.error_message", + "nfs.tag", + "nfs.opcode", + "nfs.status", + "rpc.xid", + "rpc.status", + "rpc.auth_flavor", + "rpc.cred.gids", + "rpc.cred.machinename", + "pgsql.error_message", + "pgsql.error_severity", + "pgsql.num_fields", + "pgsql.num_rows", + "redis.return_value", + "redis.error", + "thrift.params", + "thrift.service", + "thrift.return_value", + "thrift.exceptions", + "tls.detailed.version", + "tls.detailed.resumption_method", + "tls.detailed.client_hello.version", + "tls.detailed.client_hello.session_id", + "tls.detailed.client_hello.supported_compression_methods", + "tls.detailed.client_hello.extensions.server_name_indication", + "tls.detailed.client_hello.extensions.application_layer_protocol_negotiation", + "tls.detailed.client_hello.extensions.session_ticket", + "tls.detailed.client_hello.extensions.supported_versions", + "tls.detailed.client_hello.extensions.supported_groups", + "tls.detailed.client_hello.extensions.signature_algorithms", + "tls.detailed.client_hello.extensions.ec_points_formats", + "tls.detailed.client_hello.extensions._unparsed_", + "tls.detailed.server_hello.version", + "tls.detailed.server_hello.selected_compression_method", + "tls.detailed.server_hello.session_id", + "tls.detailed.server_hello.extensions.application_layer_protocol_negotiation", + "tls.detailed.server_hello.extensions.session_ticket", + "tls.detailed.server_hello.extensions.supported_versions", + "tls.detailed.server_hello.extensions.ec_points_formats", + "tls.detailed.server_hello.extensions._unparsed_", + "tls.detailed.client_certificate.serial_number", + "tls.detailed.client_certificate.public_key_algorithm", + "tls.detailed.client_certificate.signature_algorithm", + "tls.detailed.client_certificate.alternative_names", + "tls.detailed.client_certificate.subject.country", + "tls.detailed.client_certificate.subject.organization", + "tls.detailed.client_certificate.subject.organizational_unit", + "tls.detailed.client_certificate.subject.province", + "tls.detailed.client_certificate.subject.common_name", + "tls.detailed.client_certificate.subject.locality", + "tls.detailed.client_certificate.issuer.country", + "tls.detailed.client_certificate.issuer.organization", + "tls.detailed.client_certificate.issuer.organizational_unit", + "tls.detailed.client_certificate.issuer.province", + "tls.detailed.client_certificate.issuer.common_name", + "tls.detailed.client_certificate.issuer.locality", + "tls.detailed.server_certificate.serial_number", + "tls.detailed.server_certificate.public_key_algorithm", + "tls.detailed.server_certificate.signature_algorithm", + "tls.detailed.server_certificate.alternative_names", + "tls.detailed.server_certificate.subject.country", + "tls.detailed.server_certificate.subject.organization", + "tls.detailed.server_certificate.subject.organizational_unit", + "tls.detailed.server_certificate.subject.province", + "tls.detailed.server_certificate.subject.common_name", + "tls.detailed.server_certificate.subject.locality", + "tls.detailed.server_certificate.issuer.country", + "tls.detailed.server_certificate.issuer.organization", + "tls.detailed.server_certificate.issuer.organizational_unit", + "tls.detailed.server_certificate.issuer.province", + "tls.detailed.server_certificate.issuer.common_name", + "tls.detailed.server_certificate.issuer.locality", + "tls.detailed.alert_types", + "fields.*" + ] + }, + "refresh_interval": "5s" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + "beats": { + }, + "packetbeat-8.0.0": { + "is_write_index": true + }, + "packetbeat-tls": { + "filter": { + "term": { + "event.dataset": "tls" + } + } + }, + "siem-read-alias": { + } + }, + "index": "packetbeat-8.0.0-2019.08.29-000010", + "mappings": { + "_meta": { + "beat": "packetbeat", + "version": "8.0.0" + }, + "date_detection": false, + "dynamic_templates": [ + { + "labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "labels.*" + } + }, + { + "container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "container.labels.*" + } + }, + { + "dns.answers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "dns.answers.*" + } + }, + { + "fields": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "fields.*" + } + }, + { + "docker.container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "docker.container.labels.*" + } + }, + { + "kubernetes.labels.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.labels.*" + } + }, + { + "kubernetes.annotations.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.annotations.*" + } + }, + { + "amqp.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "amqp.headers.*" + } + }, + { + "cassandra.response.supported": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "cassandra.response.supported.*" + } + }, + { + "http.request.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.request.headers.*" + } + }, + { + "http.response.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.response.headers.*" + } + }, + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "@version": { + "ignore_above": 1024, + "type": "keyword" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "amqp": { + "properties": { + "app-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "arguments": { + "type": "object" + }, + "auto-delete": { + "type": "boolean" + }, + "class-id": { + "type": "long" + }, + "consumer-count": { + "type": "long" + }, + "consumer-tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-encoding": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "correlation-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-tag": { + "type": "long" + }, + "durable": { + "type": "boolean" + }, + "exchange": { + "ignore_above": 1024, + "type": "keyword" + }, + "exchange-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "exclusive": { + "type": "boolean" + }, + "expiration": { + "ignore_above": 1024, + "type": "keyword" + }, + "headers": { + "type": "object" + }, + "if-empty": { + "type": "boolean" + }, + "if-unused": { + "type": "boolean" + }, + "immediate": { + "type": "boolean" + }, + "mandatory": { + "type": "boolean" + }, + "message-count": { + "type": "long" + }, + "message-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "method-id": { + "type": "long" + }, + "multiple": { + "type": "boolean" + }, + "no-ack": { + "type": "boolean" + }, + "no-local": { + "type": "boolean" + }, + "no-wait": { + "type": "boolean" + }, + "passive": { + "type": "boolean" + }, + "priority": { + "type": "long" + }, + "queue": { + "ignore_above": 1024, + "type": "keyword" + }, + "redelivered": { + "type": "boolean" + }, + "reply-code": { + "type": "long" + }, + "reply-text": { + "ignore_above": 1024, + "type": "keyword" + }, + "reply-to": { + "ignore_above": 1024, + "type": "keyword" + }, + "routing-key": { + "ignore_above": 1024, + "type": "keyword" + }, + "timestamp": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "user-id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes_in": { + "path": "source.bytes", + "type": "alias" + }, + "bytes_out": { + "path": "destination.bytes", + "type": "alias" + }, + "cassandra": { + "properties": { + "no_request": { + "type": "boolean" + }, + "request": { + "properties": { + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "authentication": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "type": "long" + }, + "details": { + "properties": { + "alive": { + "type": "long" + }, + "arg_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "blockfor": { + "type": "long" + }, + "data_present": { + "type": "boolean" + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_failures": { + "ignore_above": 1024, + "type": "keyword" + }, + "read_consistency": { + "ignore_above": 1024, + "type": "keyword" + }, + "received": { + "type": "long" + }, + "required": { + "type": "long" + }, + "stmt_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "write_type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "host": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "result": { + "properties": { + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "prepared": { + "properties": { + "prepared_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "req_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resp_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "rows": { + "properties": { + "meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "num_rows": { + "type": "long" + } + } + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "supported": { + "type": "object" + }, + "warnings": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain_top1m_rank": { + "type": "long" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dhcpv4": { + "properties": { + "assigned_ip": { + "type": "ip" + }, + "client_ip": { + "type": "ip" + }, + "client_mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "hardware_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "hops": { + "type": "long" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "option": { + "properties": { + "boot_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "broadcast_address": { + "type": "ip" + }, + "class_identifier": { + "ignore_above": 1024, + "type": "keyword" + }, + "dns_servers": { + "type": "ip" + }, + "domain_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip_address_lease_time_sec": { + "type": "long" + }, + "max_dhcp_message_size": { + "type": "long" + }, + "message": { + "norms": false, + "type": "text" + }, + "message_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "ntp_servers": { + "type": "ip" + }, + "parameter_request_list": { + "ignore_above": 1024, + "type": "keyword" + }, + "rebinding_time_sec": { + "type": "long" + }, + "renewal_time_sec": { + "type": "long" + }, + "requested_ip_address": { + "type": "ip" + }, + "router": { + "type": "ip" + }, + "server_identifier": { + "type": "ip" + }, + "subnet_mask": { + "type": "ip" + }, + "time_servers": { + "type": "ip" + }, + "utc_time_offset_sec": { + "type": "long" + }, + "vendor_identifying_options": { + "type": "object" + } + } + }, + "relay_ip": { + "type": "ip" + }, + "seconds": { + "type": "long" + }, + "server_ip": { + "type": "ip" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "transaction_id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "dns": { + "properties": { + "additionals": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "additionals_count": { + "type": "long" + }, + "answers": { + "properties": { + "algorithm": { + "type": "keyword" + }, + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "digest_type": { + "type": "keyword" + }, + "expiration": { + "type": "keyword" + }, + "expire": { + "type": "long" + }, + "flags": { + "type": "keyword" + }, + "inception": { + "type": "keyword" + }, + "key_tag": { + "type": "keyword" + }, + "labels": { + "type": "keyword" + }, + "minimum": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_ttl": { + "type": "keyword" + }, + "protocol": { + "type": "keyword" + }, + "refresh": { + "type": "long" + }, + "retry": { + "type": "long" + }, + "rname": { + "type": "keyword" + }, + "serial": { + "type": "long" + }, + "signer_name": { + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "type_covered": { + "type": "keyword" + } + } + }, + "answers_count": { + "type": "long" + }, + "authorities": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "expire": { + "type": "long" + }, + "minimum": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "refresh": { + "type": "long" + }, + "retry": { + "type": "long" + }, + "rname": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial": { + "type": "long" + }, + "ttl": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "authorities_count": { + "type": "long" + }, + "flags": { + "properties": { + "authentic_data": { + "type": "boolean" + }, + "authoritative": { + "type": "boolean" + }, + "checking_disabled": { + "type": "boolean" + }, + "recursion_available": { + "type": "boolean" + }, + "recursion_desired": { + "type": "boolean" + }, + "truncated_response": { + "type": "boolean" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "opt": { + "properties": { + "do": { + "type": "boolean" + }, + "ext_rcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "udp_size": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "etld_plus_one": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "docker": { + "properties": { + "container": { + "properties": { + "labels": { + "type": "object" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fields": { + "type": "object" + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "target_path": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "flow": { + "properties": { + "final": { + "type": "boolean" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "type": "long" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "containerized": { + "type": "boolean" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "referer": { + "type": "keyword" + }, + "user-agent": { + "type": "keyword" + } + } + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "location": { + "type": "keyword" + }, + "user-agent": { + "type": "keyword" + } + } + }, + "status_code": { + "type": "long" + }, + "status_phrase": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "icmp": { + "properties": { + "request": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "response": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "jolokia": { + "properties": { + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "secured": { + "type": "boolean" + }, + "server": { + "properties": { + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "annotations": { + "properties": { + "*": { + "type": "object" + } + } + }, + "container": { + "properties": { + "image": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "deployment": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "properties": { + "*": { + "type": "object" + } + } + }, + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "replicaset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "statefulset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "memcache": { + "properties": { + "protocol_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "request": { + "properties": { + "automove": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "delta": { + "type": "long" + }, + "dest_class": { + "type": "long" + }, + "exptime": { + "type": "long" + }, + "flags": { + "type": "long" + }, + "initial": { + "type": "long" + }, + "line": { + "ignore_above": 1024, + "type": "keyword" + }, + "noreply": { + "type": "boolean" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "quiet": { + "type": "boolean" + }, + "raw_args": { + "ignore_above": 1024, + "type": "keyword" + }, + "sleep_us": { + "type": "long" + }, + "source_class": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vbucket": { + "type": "long" + }, + "verbosity": { + "type": "long" + } + } + }, + "response": { + "properties": { + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "error_msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "type": "long" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "status_code": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "mongodb": { + "properties": { + "cursorId": { + "ignore_above": 1024, + "type": "keyword" + }, + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "fullCollectionName": { + "ignore_above": 1024, + "type": "keyword" + }, + "numberReturned": { + "type": "long" + }, + "numberToReturn": { + "type": "long" + }, + "numberToSkip": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "returnFieldsSelector": { + "ignore_above": 1024, + "type": "keyword" + }, + "selector": { + "ignore_above": 1024, + "type": "keyword" + }, + "startingFrom": { + "ignore_above": 1024, + "type": "keyword" + }, + "update": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "mysql": { + "properties": { + "affected_rows": { + "type": "long" + }, + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "insert_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "nfs": { + "properties": { + "minor_version": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "notes": { + "path": "error.message", + "type": "alias" + }, + "observer": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "params": { + "norms": false, + "type": "text" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pgsql": { + "properties": { + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "error_severity": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "redis": { + "properties": { + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "ip": { + "type": "ip" + } + } + }, + "request": { + "norms": false, + "type": "text" + }, + "resource": { + "ignore_above": 1024, + "type": "keyword" + }, + "response": { + "norms": false, + "type": "text" + }, + "rpc": { + "properties": { + "auth_flavor": { + "ignore_above": 1024, + "type": "keyword" + }, + "cred": { + "properties": { + "gid": { + "type": "long" + }, + "gids": { + "ignore_above": 1024, + "type": "keyword" + }, + "machinename": { + "ignore_above": 1024, + "type": "keyword" + }, + "stamp": { + "type": "long" + }, + "uid": { + "type": "long" + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "xid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain_top1m_rank": { + "type": "long" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "thrift": { + "properties": { + "exceptions": { + "ignore_above": 1024, + "type": "keyword" + }, + "params": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + }, + "service": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "timeseries": { + "properties": { + "instance": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tls": { + "properties": { + "alert_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "alerts": { + "properties": { + "code": { + "type": "long" + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + }, + "source": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "client_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "fingerprint": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "raw": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "client_certificate_chain": { + "properties": { + "fingerprint": { + "properties": { + "sha1": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_before": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "client_certificate_requested": { + "type": "boolean" + }, + "client_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_name_indication": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithms": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_groups": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_compression_methods": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fingerprints": { + "properties": { + "ja3": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "str": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "handshake_completed": { + "type": "boolean" + }, + "resumed": { + "type": "boolean" + }, + "resumption_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "fingerprint": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "raw": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "postal_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "street_address": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_certificate_chain": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "fingerprint": { + "properties": { + "sha1": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_before": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "selected_cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "selected_compression_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tracing": { + "properties": { + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "codec": "best_compression", + "lifecycle": { + "name": "packetbeat-8.0.0", + "rollover_alias": "packetbeat-8.0.0" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "query": { + "default_field": [ + "message", + "tags", + "agent.ephemeral_id", + "agent.id", + "agent.name", + "agent.type", + "agent.version", + "as.organization.name", + "client.address", + "client.as.organization.name", + "client.domain", + "client.geo.city_name", + "client.geo.continent_name", + "client.geo.country_iso_code", + "client.geo.country_name", + "client.geo.name", + "client.geo.region_iso_code", + "client.geo.region_name", + "client.mac", + "client.user.domain", + "client.user.email", + "client.user.full_name", + "client.user.group.id", + "client.user.group.name", + "client.user.hash", + "client.user.id", + "client.user.name", + "cloud.account.id", + "cloud.availability_zone", + "cloud.instance.id", + "cloud.instance.name", + "cloud.machine.type", + "cloud.provider", + "cloud.region", + "container.id", + "container.image.name", + "container.image.tag", + "container.name", + "container.runtime", + "destination.address", + "destination.as.organization.name", + "destination.domain", + "destination.geo.city_name", + "destination.geo.continent_name", + "destination.geo.country_iso_code", + "destination.geo.country_name", + "destination.geo.name", + "destination.geo.region_iso_code", + "destination.geo.region_name", + "destination.mac", + "destination.user.domain", + "destination.user.email", + "destination.user.full_name", + "destination.user.group.id", + "destination.user.group.name", + "destination.user.hash", + "destination.user.id", + "destination.user.name", + "dns.answers.class", + "dns.answers.data", + "dns.answers.name", + "dns.answers.type", + "dns.header_flags", + "dns.id", + "dns.op_code", + "dns.question.class", + "dns.question.name", + "dns.question.registered_domain", + "dns.question.type", + "dns.response_code", + "dns.type", + "ecs.version", + "error.code", + "error.id", + "error.message", + "event.action", + "event.category", + "event.code", + "event.dataset", + "event.hash", + "event.id", + "event.kind", + "event.module", + "event.original", + "event.outcome", + "event.provider", + "event.timezone", + "event.type", + "file.device", + "file.directory", + "file.extension", + "file.gid", + "file.group", + "file.hash.md5", + "file.hash.sha1", + "file.hash.sha256", + "file.hash.sha512", + "file.inode", + "file.mode", + "file.name", + "file.owner", + "file.path", + "file.target_path", + "file.type", + "file.uid", + "geo.city_name", + "geo.continent_name", + "geo.country_iso_code", + "geo.country_name", + "geo.name", + "geo.region_iso_code", + "geo.region_name", + "group.id", + "group.name", + "hash.md5", + "hash.sha1", + "hash.sha256", + "hash.sha512", + "host.architecture", + "host.geo.city_name", + "host.geo.continent_name", + "host.geo.country_iso_code", + "host.geo.country_name", + "host.geo.name", + "host.geo.region_iso_code", + "host.geo.region_name", + "host.hostname", + "host.id", + "host.mac", + "host.name", + "host.os.family", + "host.os.full", + "host.os.kernel", + "host.os.name", + "host.os.platform", + "host.os.version", + "host.type", + "host.user.domain", + "host.user.email", + "host.user.full_name", + "host.user.group.id", + "host.user.group.name", + "host.user.hash", + "host.user.id", + "host.user.name", + "http.request.body.content", + "http.request.method", + "http.request.referrer", + "http.response.body.content", + "http.version", + "log.level", + "log.logger", + "log.original", + "network.application", + "network.community_id", + "network.direction", + "network.iana_number", + "network.name", + "network.protocol", + "network.transport", + "network.type", + "observer.geo.city_name", + "observer.geo.continent_name", + "observer.geo.country_iso_code", + "observer.geo.country_name", + "observer.geo.name", + "observer.geo.region_iso_code", + "observer.geo.region_name", + "observer.hostname", + "observer.mac", + "observer.os.family", + "observer.os.full", + "observer.os.kernel", + "observer.os.name", + "observer.os.platform", + "observer.os.version", + "observer.serial_number", + "observer.type", + "observer.vendor", + "observer.version", + "organization.id", + "organization.name", + "os.family", + "os.full", + "os.kernel", + "os.name", + "os.platform", + "os.version", + "process.args", + "process.executable", + "process.hash.md5", + "process.hash.sha1", + "process.hash.sha256", + "process.hash.sha512", + "process.name", + "process.thread.name", + "process.title", + "process.working_directory", + "server.address", + "server.as.organization.name", + "server.domain", + "server.geo.city_name", + "server.geo.continent_name", + "server.geo.country_iso_code", + "server.geo.country_name", + "server.geo.name", + "server.geo.region_iso_code", + "server.geo.region_name", + "server.mac", + "server.user.domain", + "server.user.email", + "server.user.full_name", + "server.user.group.id", + "server.user.group.name", + "server.user.hash", + "server.user.id", + "server.user.name", + "service.ephemeral_id", + "service.id", + "service.name", + "service.state", + "service.type", + "service.version", + "source.address", + "source.as.organization.name", + "source.domain", + "source.geo.city_name", + "source.geo.continent_name", + "source.geo.country_iso_code", + "source.geo.country_name", + "source.geo.name", + "source.geo.region_iso_code", + "source.geo.region_name", + "source.mac", + "source.user.domain", + "source.user.email", + "source.user.full_name", + "source.user.group.id", + "source.user.group.name", + "source.user.hash", + "source.user.id", + "source.user.name", + "tracing.trace.id", + "tracing.transaction.id", + "url.domain", + "url.fragment", + "url.full", + "url.original", + "url.password", + "url.path", + "url.query", + "url.scheme", + "url.username", + "user.domain", + "user.email", + "user.full_name", + "user.group.id", + "user.group.name", + "user.hash", + "user.id", + "user.name", + "user_agent.device.name", + "user_agent.name", + "user_agent.original", + "user_agent.os.family", + "user_agent.os.full", + "user_agent.os.kernel", + "user_agent.os.name", + "user_agent.os.platform", + "user_agent.os.version", + "user_agent.version", + "agent.hostname", + "error.type", + "timeseries.instance", + "cloud.project.id", + "cloud.image.id", + "host.os.build", + "host.os.codename", + "kubernetes.pod.name", + "kubernetes.pod.uid", + "kubernetes.namespace", + "kubernetes.node.name", + "kubernetes.replicaset.name", + "kubernetes.deployment.name", + "kubernetes.statefulset.name", + "kubernetes.container.name", + "kubernetes.container.image", + "jolokia.agent.version", + "jolokia.agent.id", + "jolokia.server.product", + "jolokia.server.version", + "jolokia.server.vendor", + "jolokia.url", + "type", + "server.process.name", + "server.process.args", + "server.process.executable", + "server.process.working_directory", + "server.process.start", + "client.process.name", + "client.process.args", + "client.process.executable", + "client.process.working_directory", + "client.process.start", + "flow.id", + "status", + "method", + "resource", + "path", + "query", + "params", + "request", + "response", + "amqp.reply-text", + "amqp.exchange", + "amqp.exchange-type", + "amqp.consumer-tag", + "amqp.routing-key", + "amqp.queue", + "amqp.content-type", + "amqp.content-encoding", + "amqp.delivery-mode", + "amqp.correlation-id", + "amqp.reply-to", + "amqp.expiration", + "amqp.message-id", + "amqp.timestamp", + "amqp.type", + "amqp.user-id", + "amqp.app-id", + "cassandra.request.headers.flags", + "cassandra.request.headers.stream", + "cassandra.request.headers.op", + "cassandra.request.query", + "cassandra.response.headers.flags", + "cassandra.response.headers.stream", + "cassandra.response.headers.op", + "cassandra.response.result.type", + "cassandra.response.result.rows.meta.keyspace", + "cassandra.response.result.rows.meta.table", + "cassandra.response.result.rows.meta.flags", + "cassandra.response.result.rows.meta.paging_state", + "cassandra.response.result.keyspace", + "cassandra.response.result.schema_change.change", + "cassandra.response.result.schema_change.keyspace", + "cassandra.response.result.schema_change.table", + "cassandra.response.result.schema_change.object", + "cassandra.response.result.schema_change.target", + "cassandra.response.result.schema_change.name", + "cassandra.response.result.schema_change.args", + "cassandra.response.result.prepared.prepared_id", + "cassandra.response.result.prepared.req_meta.keyspace", + "cassandra.response.result.prepared.req_meta.table", + "cassandra.response.result.prepared.req_meta.flags", + "cassandra.response.result.prepared.req_meta.paging_state", + "cassandra.response.result.prepared.resp_meta.keyspace", + "cassandra.response.result.prepared.resp_meta.table", + "cassandra.response.result.prepared.resp_meta.flags", + "cassandra.response.result.prepared.resp_meta.paging_state", + "cassandra.response.authentication.class", + "cassandra.response.warnings", + "cassandra.response.event.type", + "cassandra.response.event.change", + "cassandra.response.event.host", + "cassandra.response.event.schema_change.change", + "cassandra.response.event.schema_change.keyspace", + "cassandra.response.event.schema_change.table", + "cassandra.response.event.schema_change.object", + "cassandra.response.event.schema_change.target", + "cassandra.response.event.schema_change.name", + "cassandra.response.event.schema_change.args", + "cassandra.response.error.msg", + "cassandra.response.error.type", + "cassandra.response.error.details.read_consistency", + "cassandra.response.error.details.write_type", + "cassandra.response.error.details.keyspace", + "cassandra.response.error.details.table", + "cassandra.response.error.details.stmt_id", + "cassandra.response.error.details.num_failures", + "cassandra.response.error.details.function", + "cassandra.response.error.details.arg_types", + "dhcpv4.transaction_id", + "dhcpv4.flags", + "dhcpv4.client_mac", + "dhcpv4.server_name", + "dhcpv4.op_code", + "dhcpv4.hardware_type", + "dhcpv4.option.message_type", + "dhcpv4.option.parameter_request_list", + "dhcpv4.option.class_identifier", + "dhcpv4.option.domain_name", + "dhcpv4.option.hostname", + "dhcpv4.option.message", + "dhcpv4.option.boot_file_name", + "dns.question.etld_plus_one", + "dns.authorities.name", + "dns.authorities.type", + "dns.authorities.class", + "dns.additionals.name", + "dns.additionals.type", + "dns.additionals.class", + "dns.additionals.data", + "dns.opt.version", + "dns.opt.ext_rcode", + "http.response.status_phrase", + "icmp.version", + "icmp.request.message", + "icmp.response.message", + "memcache.protocol_type", + "memcache.request.line", + "memcache.request.command", + "memcache.response.command", + "memcache.request.type", + "memcache.response.type", + "memcache.response.error_msg", + "memcache.request.opcode", + "memcache.response.opcode", + "memcache.response.status", + "memcache.request.raw_args", + "memcache.request.automove", + "memcache.response.version", + "mongodb.error", + "mongodb.fullCollectionName", + "mongodb.startingFrom", + "mongodb.query", + "mongodb.returnFieldsSelector", + "mongodb.selector", + "mongodb.update", + "mongodb.cursorId", + "mysql.insert_id", + "mysql.num_fields", + "mysql.num_rows", + "mysql.query", + "mysql.error_message", + "nfs.tag", + "nfs.opcode", + "nfs.status", + "rpc.xid", + "rpc.status", + "rpc.auth_flavor", + "rpc.cred.gids", + "rpc.cred.machinename", + "pgsql.error_message", + "pgsql.error_severity", + "pgsql.num_fields", + "pgsql.num_rows", + "redis.return_value", + "redis.error", + "thrift.params", + "thrift.service", + "thrift.return_value", + "thrift.exceptions", + "tls.version", + "tls.resumption_method", + "tls.client_hello.version", + "tls.client_hello.extensions.server_name_indication", + "tls.client_hello.extensions.application_layer_protocol_negotiation", + "tls.client_hello.extensions.session_ticket", + "tls.client_hello.extensions.supported_versions", + "tls.client_hello.extensions.supported_groups", + "tls.client_hello.extensions.signature_algorithms", + "tls.client_hello.extensions.ec_points_formats", + "tls.client_hello.extensions._unparsed_", + "tls.server_hello.version", + "tls.server_hello.selected_cipher", + "tls.server_hello.selected_compression_method", + "tls.server_hello.session_id", + "tls.server_hello.extensions.session_ticket", + "tls.server_hello.extensions.supported_versions", + "tls.server_hello.extensions.ec_points_formats", + "tls.server_hello.extensions._unparsed_", + "tls.client_certificate.serial_number", + "tls.client_certificate.public_key_algorithm", + "tls.client_certificate.signature_algorithm", + "tls.client_certificate.raw", + "tls.client_certificate.subject.country", + "tls.client_certificate.subject.organization", + "tls.client_certificate.subject.organizational_unit", + "tls.client_certificate.subject.province", + "tls.client_certificate.subject.common_name", + "tls.client_certificate.issuer.country", + "tls.client_certificate.issuer.organization", + "tls.client_certificate.issuer.organizational_unit", + "tls.client_certificate.issuer.province", + "tls.client_certificate.issuer.common_name", + "tls.client_certificate.fingerprint.md5", + "tls.client_certificate.fingerprint.sha1", + "tls.client_certificate.fingerprint.sha256", + "tls.server_certificate.serial_number", + "tls.server_certificate.public_key_algorithm", + "tls.server_certificate.signature_algorithm", + "tls.server_certificate.raw", + "tls.server_certificate.subject.country", + "tls.server_certificate.subject.organization", + "tls.server_certificate.subject.organizational_unit", + "tls.server_certificate.subject.province", + "tls.server_certificate.subject.common_name", + "tls.server_certificate.issuer.country", + "tls.server_certificate.issuer.organization", + "tls.server_certificate.issuer.organizational_unit", + "tls.server_certificate.issuer.province", + "tls.server_certificate.issuer.common_name", + "tls.server_certificate.fingerprint.md5", + "tls.server_certificate.fingerprint.sha1", + "tls.server_certificate.fingerprint.sha256", + "tls.alert_types", + "tls.fingerprints.ja3.hash", + "tls.fingerprints.ja3.str", + "fields.*" + ] + }, + "refresh_interval": "5s" + } + } + } +} \ No newline at end of file From 1a872752b76bcd66f517dc5afbbbd7e17fe5d081 Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Tue, 24 Mar 2020 08:11:38 +0100 Subject: [PATCH 12/64] [ML] Functional tests - stabilize df analytics clone tests (#60497) This PR stabilizes the data frame analytics clone tests. --- .../classification_creation.ts | 2 +- .../data_frame_analytics/cloning.ts | 9 ++++++--- .../outlier_detection_creation.ts | 2 +- .../regression_creation.ts | 2 +- .../services/machine_learning/api.ts | 19 ++++++++++++------- .../data_frame_analytics_creation.ts | 11 +++++++---- x-pack/test/functional/services/ml.ts | 3 ++- 7 files changed, 30 insertions(+), 18 deletions(-) diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts index 1bcdeef394c00..a7c92cac2072f 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts @@ -111,7 +111,7 @@ export default function({ getService }: FtrProviderContext) { it('creates the analytics job', async () => { await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('starts the analytics job', async () => { diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts index 51155fccc358d..caf382b532273 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts @@ -13,8 +13,7 @@ export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - // failing test, see https://github.com/elastic/kibana/issues/60389 - describe.skip('jobs cloning supported by UI form', function() { + describe('jobs cloning supported by UI form', function() { this.tags(['smoke']); const testDataList: Array<{ @@ -173,7 +172,7 @@ export default function({ getService }: FtrProviderContext) { }); it('should create a clone job', async () => { - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(cloneJobId); }); it('should start the clone analytics job', async () => { @@ -186,6 +185,10 @@ export default function({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.closeCreateAnalyticsJobFlyout(); }); + it('finishes analytics processing', async () => { + await ml.dataFrameAnalytics.waitForAnalyticsCompletion(cloneJobId); + }); + it('displays the created job in the analytics table', async () => { await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); await ml.dataFrameAnalyticsTable.filterWithSearchString(cloneJobId); diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts index 173430706f7e7..5481977351d8b 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts @@ -107,7 +107,7 @@ export default function({ getService }: FtrProviderContext) { it('creates the analytics job', async () => { await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('starts the analytics job', async () => { diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts index 3bc524a88f787..aa1a133c81187 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts @@ -111,7 +111,7 @@ export default function({ getService }: FtrProviderContext) { it('creates the analytics job', async () => { await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('starts the analytics job', async () => { diff --git a/x-pack/test/functional/services/machine_learning/api.ts b/x-pack/test/functional/services/machine_learning/api.ts index e305d23c1a124..74dc5912df36f 100644 --- a/x-pack/test/functional/services/machine_learning/api.ts +++ b/x-pack/test/functional/services/machine_learning/api.ts @@ -358,9 +358,20 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { }, async getDataFrameAnalyticsJob(analyticsId: string) { + log.debug(`Fetching data frame analytics job '${analyticsId}'...`); return await esSupertest.get(`/_ml/data_frame/analytics/${analyticsId}`).expect(200); }, + async waitForDataFrameAnalyticsJobToExist(analyticsId: string) { + await retry.waitForWithTimeout(`'${analyticsId}' to exist`, 5 * 1000, async () => { + if (await this.getDataFrameAnalyticsJob(analyticsId)) { + return true; + } else { + throw new Error(`expected data frame analytics job '${analyticsId}' to exist`); + } + }); + }, + async createDataFrameAnalyticsJob(jobConfig: DataFrameAnalyticsConfig) { const { id: analyticsId, ...analyticsConfig } = jobConfig; log.debug(`Creating data frame analytic job with id '${analyticsId}'...`); @@ -369,13 +380,7 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { .send(analyticsConfig) .expect(200); - await retry.waitForWithTimeout(`'${analyticsId}' to be created`, 5 * 1000, async () => { - if (await this.getDataFrameAnalyticsJob(analyticsId)) { - return true; - } else { - throw new Error(`expected data frame analytics job '${analyticsId}' to be created`); - } - }); + await this.waitForDataFrameAnalyticsJobToExist(analyticsId); }, }; } diff --git a/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts b/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts index 9d5f5753e8b04..49f9b01c96895 100644 --- a/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts @@ -12,6 +12,7 @@ import { import { FtrProviderContext } from '../../ftr_provider_context'; import { MlCommon } from './common'; +import { MlApi } from './api'; enum ANALYSIS_CONFIG_TYPE { OUTLIER_DETECTION = 'outlier_detection', @@ -31,7 +32,8 @@ const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => { export function MachineLearningDataFrameAnalyticsCreationProvider( { getService }: FtrProviderContext, - mlCommon: MlCommon + mlCommon: MlCommon, + mlApi: MlApi ) { const testSubjects = getService('testSubjects'); const comboBox = getService('comboBox'); @@ -333,12 +335,13 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( return !isEnabled; }, - async createAnalyticsJob() { + async createAnalyticsJob(analyticsId: string) { await testSubjects.click('mlAnalyticsCreateJobFlyoutCreateButton'); await retry.tryForTime(5000, async () => { await this.assertCreateButtonMissing(); await this.assertStartButtonExists(); }); + await mlApi.waitForDataFrameAnalyticsJobToExist(analyticsId); }, async assertStartButtonExists() { @@ -362,8 +365,8 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( }, async closeCreateAnalyticsJobFlyout() { - await testSubjects.click('mlAnalyticsCreateJobFlyoutCloseButton'); - await retry.tryForTime(5000, async () => { + await retry.tryForTime(10 * 1000, async () => { + await testSubjects.click('mlAnalyticsCreateJobFlyoutCloseButton'); await testSubjects.missingOrFail('mlAnalyticsCreateJobFlyout'); }); }, diff --git a/x-pack/test/functional/services/ml.ts b/x-pack/test/functional/services/ml.ts index f3981c9edf92f..af7cb51f4e3f0 100644 --- a/x-pack/test/functional/services/ml.ts +++ b/x-pack/test/functional/services/ml.ts @@ -45,7 +45,8 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataFrameAnalytics = MachineLearningDataFrameAnalyticsProvider(context, api); const dataFrameAnalyticsCreation = MachineLearningDataFrameAnalyticsCreationProvider( context, - common + common, + api ); const dataFrameAnalyticsTable = MachineLearningDataFrameAnalyticsTableProvider(context); const dataVisualizer = MachineLearningDataVisualizerProvider(context); From 462be16879539559818649352531b35e9329908a Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 24 Mar 2020 01:14:41 -0600 Subject: [PATCH 13/64] [SIEM] Overview: Recent cases widget (#60993) ## [SIEM] Overview: Recent cases widget Implements the new `Recent cases` widget on the Overview page. Recent cases shows the last 3 recently created cases, per the following animated gif: ![recent-cases](https://user-images.githubusercontent.com/4459398/77357982-ae550a80-6d0e-11ea-90d0-62fa5407eea5.gif) ### Markdown case descriptions Markdown case descriptions are rendered, per the following animated gif: ![markdown-description](https://user-images.githubusercontent.com/4459398/77358163-f7a55a00-6d0e-11ea-8b85-dd4b3ff093ee.gif) ### My recently reported cases My recently reported cases filters the widget to show only cases created by the logged-in user, per the following animated gif: ![my-recent-cases](https://user-images.githubusercontent.com/4459398/77358223-14419200-6d0f-11ea-8e4a-25cd55fdfc44.gif) ### No cases state A message welcoming the user to create a case is displayed when no cases exist, per the following screenshot: ![no-cases-created](https://user-images.githubusercontent.com/4459398/77358338-4ce16b80-6d0f-11ea-98d3-5de1be19a935.png) ### Other changes - [x] Case-related links were updated to ensure URL state parameters, e.g. global date selection, carry-over as the user navigates through case views - [x] Recent timelines was updated to only show the last 3 recent timelines (down from 5) - [x] All sidebar widgets have slightly more compact spacing Tested in: * Chrome `80.0.3987.149` * Firefox `74.0` * Safari `13.0.5` --- .../components/link_to/redirect_to_case.tsx | 17 ++- .../siem/public/components/links/index.tsx | 13 +- .../public/components/markdown/index.test.tsx | 11 ++ .../siem/public/components/markdown/index.tsx | 90 ++++++------- .../navigation/breadcrumbs/index.ts | 17 ++- .../public/components/news_feed/news_feed.tsx | 8 +- .../components/news_feed/post/index.tsx | 1 + .../components/recent_cases/filters/index.tsx | 51 ++++++++ .../public/components/recent_cases/index.tsx | 80 ++++++++++++ .../recent_cases/no_cases/index.tsx | 34 +++++ .../components/recent_cases/recent_cases.tsx | 56 ++++++++ .../components/recent_cases/translations.ts | 37 ++++++ .../public/components/recent_cases/types.ts | 7 + .../recent_timelines/counts/index.tsx | 2 +- .../recent_timelines/filters/index.tsx | 6 +- .../components/recent_timelines/index.tsx | 12 +- .../recent_timelines/recent_timelines.tsx | 9 +- .../recent_timelines/translations.ts | 8 ++ .../components/url_state/index.test.tsx | 4 +- .../siem/public/components/url_state/types.ts | 4 +- .../public/containers/case/use_get_cases.tsx | 31 +++-- .../pages/case/components/all_cases/index.tsx | 7 +- .../pages/case/components/case_view/index.tsx | 6 +- .../case/components/configure_cases/index.tsx | 10 +- .../public/pages/case/configure_cases.tsx | 47 ++++--- .../siem/public/pages/case/create_case.tsx | 37 ++++-- .../plugins/siem/public/pages/case/utils.ts | 12 +- .../public/pages/home/home_navigations.tsx | 2 +- .../public/pages/overview/sidebar/index.tsx | 20 ++- .../public/pages/overview/sidebar/sidebar.tsx | 123 +++++++++++++----- .../public/pages/overview/translations.ts | 4 + 31 files changed, 594 insertions(+), 172 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx index 20ba0b50f5126..6ec15b55ba83d 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; +import { appendSearch } from './helpers'; import { RedirectWrapper } from './redirect_wrapper'; import { SiemPageName } from '../../pages/home/types'; @@ -30,8 +31,14 @@ export const RedirectToConfigureCasesPage = () => ( const baseCaseUrl = `#/link-to/${SiemPageName.case}`; -export const getCaseUrl = () => baseCaseUrl; -export const getCaseDetailsUrl = (detailName: string, search: string) => - `${baseCaseUrl}/${detailName}${search}`; -export const getCreateCaseUrl = (search: string) => `${baseCaseUrl}/create${search}`; -export const getConfigureCasesUrl = (search: string) => `${baseCaseUrl}/configure${search}`; +export const getCaseUrl = (search: string | null) => + `${baseCaseUrl}${appendSearch(search ?? undefined)}`; + +export const getCaseDetailsUrl = ({ id, search }: { id: string; search: string | null }) => + `${baseCaseUrl}/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; + +export const getCreateCaseUrl = (search: string | null) => + `${baseCaseUrl}/create${appendSearch(search ?? undefined)}`; + +export const getConfigureCasesUrl = (search: string) => + `${baseCaseUrl}/configure${appendSearch(search ?? undefined)}`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 935df9ad3361f..14dc5e7999a65 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -23,11 +23,11 @@ import { import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types'; import { useUiSetting$ } from '../../lib/kibana'; import { IP_REPUTATION_LINKS_SETTING } from '../../../common/constants'; -import { navTabs } from '../../pages/home/home_navigations'; import * as i18n from '../page/network/ip_overview/translations'; import { isUrlInvalid } from '../../pages/detection_engine/rules/components/step_about_rule/helpers'; -import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { ExternalLinkIcon } from '../external_link_icon'; +import { navTabs } from '../../pages/home/home_navigations'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -92,10 +92,11 @@ const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailNam children, detailName, }) => { - const urlSearch = useGetUrlSearch(navTabs.case); + const search = useGetUrlSearch(navTabs.case); + return ( {children ? children : detailName} @@ -106,8 +107,8 @@ export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => { - const urlSearch = useGetUrlSearch(navTabs.case); - return {children}; + const search = useGetUrlSearch(navTabs.case); + return {children}; }); CreateCaseLink.displayName = 'CreateCaseLink'; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx index de662c162fc0a..89af9202a597e 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx @@ -126,6 +126,17 @@ describe('Markdown', () => { ).toHaveProperty('href', 'https://google.com/'); }); + test('it does NOT render the href if links are disabled', () => { + const wrapper = mount(); + + expect( + wrapper + .find('[data-test-subj="markdown-link"]') + .first() + .getDOMNode() + ).not.toHaveProperty('href'); + }); + test('it opens links in a new tab via target="_blank"', () => { const wrapper = mount(); diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx index 30695c9d0c7e2..1368c13619d6b 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx @@ -26,51 +26,53 @@ const REL_NOFOLLOW = 'nofollow'; /** prevents the browser from sending the current address as referrer via the Referer HTTP header */ const REL_NOREFERRER = 'noreferrer'; -export const Markdown = React.memo<{ raw?: string; size?: 'xs' | 's' | 'm' }>( - ({ raw, size = 's' }) => { - const markdownRenderers = { - root: ({ children }: { children: React.ReactNode[] }) => ( - +export const Markdown = React.memo<{ + disableLinks?: boolean; + raw?: string; + size?: 'xs' | 's' | 'm'; +}>(({ disableLinks = false, raw, size = 's' }) => { + const markdownRenderers = { + root: ({ children }: { children: React.ReactNode[] }) => ( + + {children} + + ), + table: ({ children }: { children: React.ReactNode[] }) => ( + + {children} +
+ ), + tableHead: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + tableRow: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + tableCell: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( + + {children} -
- ), - table: ({ children }: { children: React.ReactNode[] }) => ( - - {children} -
- ), - tableHead: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableRow: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableCell: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( - - - {children} - - - ), - }; +
+
+ ), + }; - return ( - - ); - } -); + return ( + + ); +}); Markdown.displayName = 'Markdown'; diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts index e25fb4374bb14..155f63145ca95 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts +++ b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts @@ -107,7 +107,22 @@ export const getBreadcrumbsForRoute = ( ]; } if (isCaseRoutes(spyState) && object.navTabs) { - return [...siemRootBreadcrumb, ...getCaseDetailsBreadcrumbs(spyState)]; + const tempNav: SearchNavTab = { urlKey: 'case', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + ...siemRootBreadcrumb, + ...getCaseDetailsBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ) + ), + ]; } if ( spyState != null && diff --git a/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx b/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx index 98eea1eaa6454..cd356212b4400 100644 --- a/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx +++ b/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer } from '@elastic/eui'; import React from 'react'; import { LoadingPlaceholders } from '../page/overview/loading_placeholders'; @@ -30,12 +29,7 @@ const NewsFeedComponent: React.FC = ({ news }) => ( ) : news.length === 0 ? ( ) : ( - news.map((n: NewsItem) => ( - - - - - )) + news.map((n: NewsItem) => ) )} ); diff --git a/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx b/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx index cb2542a497f08..9cab78c9f20b1 100644 --- a/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx @@ -45,6 +45,7 @@ export const Post = React.memo<{ newsItem: NewsItem }>(({ newsItem }) => {
{description}
+
diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx new file mode 100644 index 0000000000000..edb0b99cbff8b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonGroup, EuiButtonGroupOption } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; + +import { FilterMode } from '../types'; + +import * as i18n from '../translations'; + +const MY_RECENTLY_REPORTED_ID = 'myRecentlyReported'; + +const toggleButtonIcons: EuiButtonGroupOption[] = [ + { + id: 'recentlyCreated', + label: i18n.RECENTLY_CREATED_CASES, + iconType: 'folderExclamation', + }, + { + id: MY_RECENTLY_REPORTED_ID, + label: i18n.MY_RECENTLY_REPORTED_CASES, + iconType: 'reporter', + }, +]; + +export const Filters = React.memo<{ + filterBy: FilterMode; + setFilterBy: (filterBy: FilterMode) => void; + showMyRecentlyReported: boolean; +}>(({ filterBy, setFilterBy, showMyRecentlyReported }) => { + const options = useMemo( + () => + showMyRecentlyReported + ? toggleButtonIcons + : toggleButtonIcons.filter(x => x.id !== MY_RECENTLY_REPORTED_ID), + [showMyRecentlyReported] + ); + const onChange = useCallback( + (filterMode: string) => { + setFilterBy(filterMode as FilterMode); + }, + [setFilterBy] + ); + + return ; +}); + +Filters.displayName = 'Filters'; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx new file mode 100644 index 0000000000000..07246c6c6ec88 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHorizontalRule, EuiLink, EuiText } from '@elastic/eui'; +import React, { useEffect, useMemo, useRef } from 'react'; + +import { FilterOptions, QueryParams } from '../../containers/case/types'; +import { DEFAULT_QUERY_PARAMS, useGetCases } from '../../containers/case/use_get_cases'; +import { getCaseUrl } from '../link_to/redirect_to_case'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { LoadingPlaceholders } from '../page/overview/loading_placeholders'; +import { navTabs } from '../../pages/home/home_navigations'; + +import { NoCases } from './no_cases'; +import { RecentCases } from './recent_cases'; +import * as i18n from './translations'; + +const usePrevious = (value: FilterOptions) => { + const ref = useRef(); + useEffect(() => { + (ref.current as unknown) = value; + }); + return ref.current; +}; + +const MAX_CASES_TO_SHOW = 3; + +const queryParams: QueryParams = { + ...DEFAULT_QUERY_PARAMS, + perPage: MAX_CASES_TO_SHOW, +}; + +const StatefulRecentCasesComponent = React.memo( + ({ filterOptions }: { filterOptions: FilterOptions }) => { + const previousFilterOptions = usePrevious(filterOptions); + const { data, loading, setFilters } = useGetCases(queryParams); + const isLoadingCases = useMemo( + () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, + [loading] + ); + const search = useGetUrlSearch(navTabs.case); + const allCasesLink = useMemo( + () => {i18n.VIEW_ALL_CASES}, + [search] + ); + + useEffect(() => { + if (previousFilterOptions !== undefined && previousFilterOptions !== filterOptions) { + setFilters(filterOptions); + } + }, [previousFilterOptions, filterOptions, setFilters]); + + const content = useMemo( + () => + isLoadingCases ? ( + + ) : !isLoadingCases && data.cases.length === 0 ? ( + + ) : ( + + ), + [isLoadingCases, data] + ); + + return ( + + {content} + + {allCasesLink} + + ); + } +); + +StatefulRecentCasesComponent.displayName = 'StatefulRecentCasesComponent'; + +export const StatefulRecentCases = React.memo(StatefulRecentCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx new file mode 100644 index 0000000000000..9f0361311b7b6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiLink } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { getCreateCaseUrl } from '../../link_to/redirect_to_case'; +import { useGetUrlSearch } from '../../navigation/use_get_url_search'; +import { navTabs } from '../../../pages/home/home_navigations'; + +import * as i18n from '../translations'; + +const NoCasesComponent = () => { + const urlSearch = useGetUrlSearch(navTabs.case); + const newCaseLink = useMemo( + () => {` ${i18n.START_A_NEW_CASE}`}, + [urlSearch] + ); + + return ( + <> + {i18n.NO_CASES} + {newCaseLink} + {'!'} + + ); +}; + +NoCasesComponent.displayName = 'NoCasesComponent'; + +export const NoCases = React.memo(NoCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx new file mode 100644 index 0000000000000..eb17c75f4111b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import { Case } from '../../containers/case/types'; +import { getCaseDetailsUrl } from '../link_to/redirect_to_case'; +import { Markdown } from '../markdown'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { navTabs } from '../../pages/home/home_navigations'; +import { IconWithCount } from '../recent_timelines/counts'; + +import * as i18n from './translations'; + +const MarkdownContainer = styled.div` + max-height: 150px; + overflow-y: auto; + width: 300px; +`; + +const RecentCasesComponent = ({ cases }: { cases: Case[] }) => { + const search = useGetUrlSearch(navTabs.case); + + return ( + <> + {cases.map((c, i) => ( + + + + {c.title} + + + + {c.description && c.description.length && ( + + + + + + )} + {i !== cases.length - 1 && } + + + ))} + + ); +}; + +RecentCasesComponent.displayName = 'RecentCasesComponent'; + +export const RecentCases = React.memo(RecentCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts b/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts new file mode 100644 index 0000000000000..d2318e5db88c3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const COMMENTS = i18n.translate('xpack.siem.recentCases.commentsTooltip', { + defaultMessage: 'Comments', +}); + +export const MY_RECENTLY_REPORTED_CASES = i18n.translate( + 'xpack.siem.overview.myRecentlyReportedCasesButtonLabel', + { + defaultMessage: 'My recently reported cases', + } +); + +export const NO_CASES = i18n.translate('xpack.siem.recentCases.noCasesMessage', { + defaultMessage: 'No cases have been created yet. Put your detective hat on and', +}); + +export const RECENTLY_CREATED_CASES = i18n.translate( + 'xpack.siem.overview.recentlyCreatedCasesButtonLabel', + { + defaultMessage: 'Recently created cases', + } +); + +export const START_A_NEW_CASE = i18n.translate('xpack.siem.recentCases.startNewCaseLink', { + defaultMessage: 'start a new case', +}); + +export const VIEW_ALL_CASES = i18n.translate('xpack.siem.recentCases.viewAllCasesLink', { + defaultMessage: 'View all cases', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts b/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts new file mode 100644 index 0000000000000..29c7072ce0be6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type FilterMode = 'recentlyCreated' | 'myRecentlyReported'; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx index e04b6319cfb24..c80530b245cf3 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx @@ -21,7 +21,7 @@ const FlexGroup = styled(EuiFlexGroup)` margin-right: 16px; `; -const IconWithCount = React.memo<{ count: number; icon: string; tooltip: string }>( +export const IconWithCount = React.memo<{ count: number; icon: string; tooltip: string }>( ({ count, icon, tooltip }) => ( diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx index de8a3de8094d0..d7271197b9cea 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx @@ -9,15 +9,17 @@ import React from 'react'; import { FilterMode } from '../types'; +import * as i18n from '../translations'; + const toggleButtonIcons: EuiButtonGroupOption[] = [ { id: 'favorites', - label: 'Favorites', + label: i18n.FAVORITES, iconType: 'starFilled', }, { id: `recently-updated`, - label: 'Last updated', + label: i18n.LAST_UPDATED, iconType: 'documentEdit', }, ]; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx index 007665b47dedb..5b851701b973c 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx @@ -31,6 +31,8 @@ interface OwnProps { export type Props = OwnProps & PropsFromRedux; +const PAGE_SIZE = 3; + const StatefulRecentTimelinesComponent = React.memo( ({ apolloClient, filterBy, updateIsLoading, updateTimeline }) => { const onOpenTimeline: OnOpenTimeline = useCallback( @@ -53,12 +55,18 @@ const StatefulRecentTimelinesComponent = React.memo( () => {i18n.VIEW_ALL_TIMELINES}, [urlSearch] ); + const loadingPlaceholders = useMemo( + () => ( + + ), + [filterBy] + ); return ( ( {({ timelines, loading }) => ( <> {loading ? ( - + loadingPlaceholders ) : ( {t.description && t.description.length && ( - <> - - - {t.description} - - + + {t.description} + )}
diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts b/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts index e547272fde6e1..f5934aa317242 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts @@ -13,6 +13,10 @@ export const ERROR_RETRIEVING_USER_DETAILS = i18n.translate( } ); +export const FAVORITES = i18n.translate('xpack.siem.recentTimelines.favoritesButtonLabel', { + defaultMessage: 'Favorites', +}); + export const NO_FAVORITE_TIMELINES = i18n.translate( 'xpack.siem.recentTimelines.noFavoriteTimelinesMessage', { @@ -21,6 +25,10 @@ export const NO_FAVORITE_TIMELINES = i18n.translate( } ); +export const LAST_UPDATED = i18n.translate('xpack.siem.recentTimelines.lastUpdatedButtonLabel', { + defaultMessage: 'Last updated', +}); + export const NO_TIMELINES = i18n.translate('xpack.siem.recentTimelines.noTimelinesMessage', { defaultMessage: "You haven't created any timelines yet. Get out there and start threat hunting!", }); diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx index 6e957313d9b04..4d2a717153894 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx @@ -157,9 +157,7 @@ describe('UrlStateContainer', () => { ).toEqual({ hash: '', pathname: examplePath, - search: [CONSTANTS.timelinePage].includes(page) - ? `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))` - : `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, + search: `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, state: '', }); } diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index c6f49d8a0e49b..9d8a4a8e6a908 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -64,15 +64,15 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.appQuery, CONSTANTS.filters, CONSTANTS.savedQuery, - CONSTANTS.timeline, CONSTANTS.timerange, + CONSTANTS.timeline, ], case: [ CONSTANTS.appQuery, CONSTANTS.filters, CONSTANTS.savedQuery, - CONSTANTS.timeline, CONSTANTS.timerange, + CONSTANTS.timeline, ], }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index 6c4a6ac4fe58a..ae7b8f3c043fa 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -88,6 +88,20 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS } }; +export const DEFAULT_FILTER_OPTIONS: FilterOptions = { + search: '', + reporters: [], + status: 'open', + tags: [], +}; + +export const DEFAULT_QUERY_PARAMS: QueryParams = { + page: DEFAULT_TABLE_ACTIVE_PAGE, + perPage: DEFAULT_TABLE_LIMIT, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', +}; + const initialData: AllCases = { cases: [], countClosedCases: null, @@ -109,23 +123,14 @@ interface UseGetCases extends UseGetCasesState { setQueryParams: (queryParams: QueryParams) => void; setSelectedCases: (mySelectedCases: Case[]) => void; } -export const useGetCases = (): UseGetCases => { + +export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { const [state, dispatch] = useReducer(dataFetchReducer, { data: initialData, - filterOptions: { - search: '', - reporters: [], - status: 'open', - tags: [], - }, + filterOptions: DEFAULT_FILTER_OPTIONS, isError: false, loading: [], - queryParams: { - page: DEFAULT_TABLE_ACTIVE_PAGE, - perPage: DEFAULT_TABLE_LIMIT, - sortField: SortFieldCase.createdAt, - sortOrder: 'desc', - }, + queryParams: initialQueryParams ?? DEFAULT_QUERY_PARAMS, selectedCases: [], }); const [, dispatchToaster] = useStateToaster(); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 87a2ea888831a..cbb9ddae22d04 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -26,6 +26,7 @@ import { useGetCases, UpdateCase } from '../../../../containers/case/use_get_cas import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status'; import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { Panel } from '../../../../components/panel'; import { UtilityBar, @@ -35,16 +36,15 @@ import { UtilityBarText, } from '../../../../components/utility_bar'; import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; -import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; -import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; -import { navTabs } from '../../../home/home_navigations'; import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { OpenClosedStats } from '../open_closed_stats'; +import { navTabs } from '../../../home/home_navigations'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -77,6 +77,7 @@ const getSortField = (field: string): SortFieldCase => { }; export const AllCases = React.memo(() => { const urlSearch = useGetUrlSearch(navTabs.case); + const { countClosedCases, countOpenCases, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 742921cb9f69e..5c20b53f5fcb9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -25,11 +25,13 @@ import { useGetCase } from '../../../../containers/case/use_get_case'; import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { WrapperPage } from '../../../../components/wrapper_page'; import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; +import { navTabs } from '../../../home/home_navigations'; import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; import { usePushToService } from './push_to_service'; @@ -61,6 +63,8 @@ export interface CaseProps { export const CaseComponent = React.memo(({ caseId, initialData }) => { const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; + const search = useGetUrlSearch(navTabs.case); + const [initLoadingData, setInitLoadingData] = useState(true); const { caseUserActions, @@ -190,7 +194,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => css` @@ -73,6 +72,7 @@ const actionTypes: ActionType[] = [ ]; const ConfigureCasesComponent: React.FC = () => { + const search = useGetUrlSearch(navTabs.case); const { http, triggers_actions_ui, notifications, application } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); @@ -235,7 +235,7 @@ const ConfigureCasesComponent: React.FC = () => { isDisabled={isLoadingAny} isLoading={persistLoading} aria-label="Cancel" - href={CASE_URL} + href={getCaseUrl(search)} > {i18n.CANCEL} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx index b546a88744439..b7e7ced308331 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { WrapperPage } from '../../components/wrapper_page'; import { CaseHeaderPage } from './components/case_header_page'; @@ -13,11 +13,8 @@ import { getCaseUrl } from '../../components/link_to'; import { WhitePageWrapper, SectionWrapper } from './components/wrappers'; import * as i18n from './translations'; import { ConfigureCases } from './components/configure_cases'; - -const backOptions = { - href: getCaseUrl(), - text: i18n.BACK_TO_ALL, -}; +import { useGetUrlSearch } from '../../components/navigation/use_get_url_search'; +import { navTabs } from '../home/home_navigations'; const wrapperPageStyle: Record = { paddingLeft: '0', @@ -25,18 +22,30 @@ const wrapperPageStyle: Record = { paddingBottom: '0', }; -const ConfigureCasesPageComponent: React.FC = () => ( - <> - - - - - - - - - - -); +const ConfigureCasesPageComponent: React.FC = () => { + const search = useGetUrlSearch(navTabs.case); + + const backOptions = useMemo( + () => ({ + href: getCaseUrl(search), + text: i18n.BACK_TO_ALL, + }), + [search] + ); + + return ( + <> + + + + + + + + + + + ); +}; export const ConfigureCasesPage = React.memo(ConfigureCasesPageComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx index 2c7525264f71b..bd1f6da0ca28b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { WrapperPage } from '../../components/wrapper_page'; import { Create } from './components/create'; @@ -12,20 +12,29 @@ import { SpyRoute } from '../../utils/route/spy_routes'; import { CaseHeaderPage } from './components/case_header_page'; import * as i18n from './translations'; import { getCaseUrl } from '../../components/link_to'; +import { useGetUrlSearch } from '../../components/navigation/use_get_url_search'; +import { navTabs } from '../home/home_navigations'; -const backOptions = { - href: getCaseUrl(), - text: i18n.BACK_TO_ALL, -}; +export const CreateCasePage = React.memo(() => { + const search = useGetUrlSearch(navTabs.case); -export const CreateCasePage = React.memo(() => ( - <> - - - - - - -)); + const backOptions = useMemo( + () => ({ + href: getCaseUrl(search), + text: i18n.BACK_TO_ALL, + }), + [search] + ); + + return ( + <> + + + + + + + ); +}); CreateCasePage.displayName = 'CreateCasePage'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index 3f2964b8cdd6d..df9f0d08e728c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import { Breadcrumb } from 'ui/chrome'; + import { getCaseDetailsUrl, getCaseUrl, getCreateCaseUrl } from '../../components/link_to'; import { RouteSpyState } from '../../utils/route/types'; import * as i18n from './translations'; -export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { +export const getBreadcrumbs = (params: RouteSpyState, search: string[]): Breadcrumb[] => { + const queryParameters = !isEmpty(search[0]) ? search[0] : null; + let breadcrumb = [ { text: i18n.PAGE_TITLE, - href: getCaseUrl(), + href: getCaseUrl(queryParameters), }, ]; if (params.detailName === 'create') { @@ -21,7 +25,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: i18n.CREATE_BC_TITLE, - href: getCreateCaseUrl(''), + href: getCreateCaseUrl(queryParameters), }, ]; } else if (params.detailName != null) { @@ -29,7 +33,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: params.state?.caseTitle ?? '', - href: getCaseDetailsUrl(params.detailName, ''), + href: getCaseDetailsUrl({ id: params.detailName, search: queryParameters }), }, ]; } diff --git a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx index a087dca38de00..543469e2fddb7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx @@ -54,7 +54,7 @@ export const navTabs: SiemNavTab = { [SiemPageName.case]: { id: SiemPageName.case, name: i18n.CASE, - href: getCaseUrl(), + href: getCaseUrl(null), disabled: false, urlKey: 'case', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx index ad2821edde411..3797eae2bb853 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx @@ -6,13 +6,27 @@ import React, { useState } from 'react'; -import { FilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentTimelinesFilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentCasesFilterMode } from '../../../components/recent_cases/types'; + import { Sidebar } from './sidebar'; export const StatefulSidebar = React.memo(() => { - const [filterBy, setFilterBy] = useState('favorites'); + const [recentTimelinesFilterBy, setRecentTimelinesFilterBy] = useState( + 'favorites' + ); + const [recentCasesFilterBy, setRecentCasesFilterBy] = useState( + 'recentlyCreated' + ); - return ; + return ( + + ); }); StatefulSidebar.displayName = 'StatefulSidebar'; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx index d3b85afe62a2a..52e36b472a0ec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx @@ -8,12 +8,17 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { Filters } from '../../../components/recent_timelines/filters'; +import { Filters as RecentCasesFilters } from '../../../components/recent_cases/filters'; +import { Filters as RecentTimelinesFilters } from '../../../components/recent_timelines/filters'; import { ENABLE_NEWS_FEED_SETTING, NEWS_FEED_URL_SETTING } from '../../../../common/constants'; +import { StatefulRecentCases } from '../../../components/recent_cases'; import { StatefulRecentTimelines } from '../../../components/recent_timelines'; import { StatefulNewsFeed } from '../../../components/news_feed'; -import { FilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentTimelinesFilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentCasesFilterMode } from '../../../components/recent_cases/types'; +import { DEFAULT_FILTER_OPTIONS } from '../../../containers/case/use_get_cases'; import { SidebarHeader } from '../../../components/sidebar_header'; +import { useCurrentUser } from '../../../lib/kibana'; import { useApolloClient } from '../../../utils/apollo_context'; import * as i18n from '../translations'; @@ -22,35 +27,93 @@ const SidebarFlexGroup = styled(EuiFlexGroup)` width: 305px; `; +const SidebarSpacerComponent = () => ( + + + +); + +SidebarSpacerComponent.displayName = 'SidebarSpacerComponent'; +const Spacer = React.memo(SidebarSpacerComponent); + export const Sidebar = React.memo<{ - filterBy: FilterMode; - setFilterBy: (filterBy: FilterMode) => void; -}>(({ filterBy, setFilterBy }) => { - const apolloClient = useApolloClient(); - const RecentTimelinesFilters = useMemo( - () => , - [filterBy, setFilterBy] - ); - - return ( - - - {RecentTimelinesFilters} - - - - - - - - - void; + setRecentTimelinesFilterBy: (filterBy: RecentTimelinesFilterMode) => void; +}>( + ({ + recentCasesFilterBy, + recentTimelinesFilterBy, + setRecentCasesFilterBy, + setRecentTimelinesFilterBy, + }) => { + const currentUser = useCurrentUser(); + const apolloClient = useApolloClient(); + const recentCasesFilters = useMemo( + () => ( + - - - ); -}); + ), + [currentUser, recentCasesFilterBy, setRecentCasesFilterBy] + ); + const recentCasesFilterOptions = useMemo( + () => + recentCasesFilterBy === 'myRecentlyReported' && currentUser != null + ? { + ...DEFAULT_FILTER_OPTIONS, + reporters: [ + { + email: currentUser.email, + full_name: currentUser.fullName, + username: currentUser.username, + }, + ], + } + : DEFAULT_FILTER_OPTIONS, + [currentUser, recentCasesFilterBy] + ); + const recentTimelinesFilters = useMemo( + () => ( + + ), + [recentTimelinesFilterBy, setRecentTimelinesFilterBy] + ); + + return ( + + + {recentCasesFilters} + + + + + + + {recentTimelinesFilters} + + + + + + + + + + ); + } +); Sidebar.displayName = 'Sidebar'; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts index 5ccd25984bc40..601a629d86e57 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts @@ -26,6 +26,10 @@ export const PAGE_SUBTITLE = i18n.translate('xpack.siem.overview.pageSubtitle', defaultMessage: 'Security Information & Event Management with the Elastic Stack', }); +export const RECENT_CASES = i18n.translate('xpack.siem.overview.recentCasesSidebarTitle', { + defaultMessage: 'Recent cases', +}); + export const RECENT_TIMELINES = i18n.translate('xpack.siem.overview.recentTimelinesSidebarTitle', { defaultMessage: 'Recent timelines', }); From e96ed69bf69a48cd6575d9e10925bf4e1e8685f0 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 24 Mar 2020 08:26:09 +0100 Subject: [PATCH 14/64] Upgrade mocha dev-dependency from 6.2.2 to 7.1.1 (#60779) Co-authored-by: spalger --- package.json | 4 +- x-pack/package.json | 4 +- yarn.lock | 237 ++++++++++++++++++++------------------------ 3 files changed, 111 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index 3421bf938cd80..08668730f9a9d 100644 --- a/package.json +++ b/package.json @@ -348,7 +348,7 @@ "@types/lru-cache": "^5.1.0", "@types/markdown-it": "^0.0.7", "@types/minimatch": "^2.0.29", - "@types/mocha": "^5.2.7", + "@types/mocha": "^7.0.2", "@types/moment-timezone": "^0.5.12", "@types/mustache": "^0.8.31", "@types/node": ">=10.17.17 <10.20.0", @@ -456,7 +456,7 @@ "license-checker": "^16.0.0", "listr": "^0.14.1", "load-grunt-config": "^3.0.1", - "mocha": "^6.2.2", + "mocha": "^7.1.1", "mock-http-server": "1.3.0", "multistream": "^2.1.1", "murmurhash3js": "3.0.1", diff --git a/x-pack/package.json b/x-pack/package.json index 116bbb92007e7..41674cac01725 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -78,7 +78,7 @@ "@types/mapbox-gl": "^0.54.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", - "@types/mocha": "^5.2.7", + "@types/mocha": "^7.0.2", "@types/nock": "^10.0.3", "@types/node": ">=10.17.17 <10.20.0", "@types/node-fetch": "^2.5.0", @@ -145,7 +145,7 @@ "loader-utils": "^1.2.3", "madge": "3.4.4", "marge": "^1.0.1", - "mocha": "^6.2.2", + "mocha": "^7.1.1", "mocha-junit-reporter": "^1.23.1", "mochawesome": "^4.1.0", "mochawesome-merge": "^2.0.1", diff --git a/yarn.lock b/yarn.lock index bb5032f51c6c7..22b087d5a8338 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4841,10 +4841,10 @@ dependencies: "@types/node" "*" -"@types/mocha@^5.2.7": - version "5.2.7" - resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" - integrity sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ== +"@types/mocha@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-7.0.2.tgz#b17f16cf933597e10d6d78eae3251e692ce8b0ce" + integrity sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w== "@types/moment-timezone@^0.5.12": version "0.5.12" @@ -7934,6 +7934,13 @@ binaryextensions@2: resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.1.tgz#3209a51ca4a4ad541a3b8d3d6a6d5b83a2485935" integrity sha512-XBaoWE9RW8pPdPQNibZsW2zh8TW6gcarXp1FZPwT8Uop8ScSNldJEWf2k9l3HeTqdrEwsOsFcq74RiJECW34yA== +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bit-twiddle@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bit-twiddle/-/bit-twiddle-1.0.2.tgz#0c6c1fabe2b23d17173d9a61b7b7093eb9e1769e" @@ -9084,6 +9091,21 @@ chokidar@3.2.1: optionalDependencies: fsevents "~2.1.0" +chokidar@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6" + integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.2.0" + optionalDependencies: + fsevents "~2.1.1" + chokidar@^2.0.0, chokidar@^2.1.2, chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -11084,7 +11106,7 @@ debug-fabulous@1.X: memoizee "0.4.X" object-assign "4.X" -debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: +debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -11478,11 +11500,6 @@ detect-indent@^4.0.0: dependencies: repeating "^2.0.0" -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detect-newline@2.X, detect-newline@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" @@ -13852,6 +13869,11 @@ file-type@^9.0.0: resolved "https://registry.yarnpkg.com/file-type/-/file-type-9.0.0.tgz#a68d5ad07f486414dfb2c8866f73161946714a18" integrity sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw== +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + filename-reserved-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz#abf73dfab735d045440abfea2d91f389ebbfa229" @@ -14473,17 +14495,17 @@ fs.realpath@^1.0.0: integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" - integrity sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw== + version "1.2.12" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.12.tgz#db7e0d8ec3b0b45724fd4d83d43554a8f1f0de5c" + integrity sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q== dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" + bindings "^1.5.0" + nan "^2.12.1" -fsevents@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.0.tgz#ce1a5f9ac71c6d75278b0c5bd236d7dfece4cbaa" - integrity sha512-+iXhW3LuDQsno8dOIrCIT/CBjeBWuP7PXe8w9shnj9Lebny/Gx1ZjVBYwexLz36Ri2jKuXMNpV6CYNh8lHHgrQ== +fsevents@~2.1.0, fsevents@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" + integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== fstream@^1.0.0, fstream@^1.0.12: version "1.0.12" @@ -16502,7 +16524,7 @@ icalendar@0.7.1: resolved "https://registry.yarnpkg.com/icalendar/-/icalendar-0.7.1.tgz#d0d3486795f8f1c5cf4f8cafac081b4b4e7a32ae" integrity sha1-0NNIZ5X48cXPT4yvrAgbS056Mq4= -iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -16576,13 +16598,6 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" - integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== - dependencies: - minimatch "^3.0.4" - ignore@^3.1.2, ignore@^3.3.5: version "3.3.10" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" @@ -20712,6 +20727,11 @@ minimist@^0.1.0: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.1.0.tgz#99df657a52574c21c9057497df742790b2b4c0de" integrity sha1-md9lelJXTCHJBXSX33QnkLK0wN4= +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + minimist@~0.0.1: version "0.0.10" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" @@ -20754,7 +20774,7 @@ minipass@^2.2.1: safe-buffer "^5.1.1" yallist "^3.0.0" -minipass@^2.2.4, minipass@^2.3.4: +minipass@^2.2.4: version "2.3.5" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA== @@ -20784,13 +20804,6 @@ minizlib@^1.1.0, minizlib@^1.2.1: dependencies: minipass "^2.2.1" -minizlib@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.1.tgz#6734acc045a46e61d596a43bb9d9cd326e19cc42" - integrity sha512-TrfjCjk4jLhcJyGMYymBH6oTXcWjYbUAXTHDbtnWHjZC25h0cdajHuPE1zxb4DVmu8crfh+HwH/WMuyLG0nHBg== - dependencies: - minipass "^2.2.1" - mississippi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" @@ -20842,6 +20855,13 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi dependencies: minimist "0.0.8" +mkdirp@0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.3.tgz#5a514b7179259287952881e94410ec5465659f8c" + integrity sha512-P+2gwrFqx8lhew375MQHHeTlY8AuOJSrGf0R5ddkEndUkmwpgUob/vQuBD1V22/Cw1/lJr4x+EjllSezBThzBg== + dependencies: + minimist "^1.2.5" + mkdirp@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.3.tgz#4cf2e30ad45959dddea53ad97d518b6c8205e1ea" @@ -20858,13 +20878,14 @@ mocha-junit-reporter@^1.23.1: strip-ansi "^4.0.0" xml "^1.0.0" -mocha@^6.2.2: - version "6.2.2" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-6.2.2.tgz#5d8987e28940caf8957a7d7664b910dc5b2fea20" - integrity sha512-FgDS9Re79yU1xz5d+C4rv1G7QagNGHZ+iXF81hO8zY35YZZcLEsJVfFolfsqKFWunATEvNzMK0r/CwWd/szO9A== +mocha@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.1.1.tgz#89fbb30d09429845b1bb893a830bf5771049a441" + integrity sha512-3qQsu3ijNS3GkWcccT5Zw0hf/rWvu1fTN9sPvEd81hlwsr30GX2GcDSSoBxo24IR8FelmrAydGC6/1J5QQP4WA== dependencies: ansi-colors "3.2.3" browser-stdout "1.3.1" + chokidar "3.3.0" debug "3.2.6" diff "3.5.0" escape-string-regexp "1.0.5" @@ -20873,18 +20894,18 @@ mocha@^6.2.2: growl "1.10.5" he "1.2.0" js-yaml "3.13.1" - log-symbols "2.2.0" + log-symbols "3.0.0" minimatch "3.0.4" - mkdirp "0.5.1" + mkdirp "0.5.3" ms "2.1.1" - node-environment-flags "1.0.5" + node-environment-flags "1.0.6" object.assign "4.1.0" strip-json-comments "2.0.1" supports-color "6.0.0" which "1.3.1" wide-align "1.1.3" - yargs "13.3.0" - yargs-parser "13.1.1" + yargs "13.3.2" + yargs-parser "13.1.2" yargs-unparser "1.6.0" mochawesome-merge@^2.0.1: @@ -21140,7 +21161,7 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nan@^2.13.2, nan@^2.9.2: +nan@^2.12.1, nan@^2.13.2: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== @@ -21222,15 +21243,6 @@ nearley@^2.7.10: randexp "0.4.6" semver "^5.4.1" -needle@^2.2.1: - version "2.2.4" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e" - integrity sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA== - dependencies: - debug "^2.1.2" - iconv-lite "^0.4.4" - sax "^1.2.4" - negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" @@ -21351,10 +21363,10 @@ node-dir@^0.1.10: dependencies: minimatch "^3.0.2" -node-environment-flags@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.5.tgz#fa930275f5bf5dae188d6192b24b4c8bbac3d76a" - integrity sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ== +node-environment-flags@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" + integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw== dependencies: object.getownpropertydescriptors "^2.0.3" semver "^5.7.0" @@ -21515,22 +21527,6 @@ node-notifier@^5.4.2: shellwords "^0.1.1" which "^1.3.0" -node-pre-gyp@^0.10.0: - version "0.10.3" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" - integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - node-releases@^1.1.25, node-releases@^1.1.46: version "1.1.47" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.47.tgz#c59ef739a1fd7ecbd9f0b7cf5b7871e8a8b591e4" @@ -21600,14 +21596,6 @@ nopt@^2.2.0: dependencies: abbrev "1" -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= - dependencies: - abbrev "1" - osenv "^0.1.4" - normalize-package-data@^2.0.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -21676,11 +21664,6 @@ now-and-later@^2.0.0: dependencies: once "^1.3.2" -npm-bundled@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.3.tgz#7e71703d973af3370a9591bafe3a63aca0be2308" - integrity sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow== - npm-conf@^1.1.0, npm-conf@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/npm-conf/-/npm-conf-1.1.3.tgz#256cc47bd0e218c259c4e9550bf413bc2192aff9" @@ -21697,14 +21680,6 @@ npm-keyword@^5.0.0: got "^7.1.0" registry-url "^3.0.3" -npm-packlist@^1.1.6: - version "1.1.10" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.10.tgz#1039db9e985727e464df066f4cf0ab6ef85c398a" - integrity sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - npm-run-path@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-1.0.0.tgz#f5c32bf595fe81ae927daec52e82f8b000ac3c8f" @@ -21742,7 +21717,7 @@ npmconf@^2.1.3: semver "2 || 3 || 4" uid-number "0.0.5" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2, npmlog@^4.1.2: +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -22324,14 +22299,6 @@ osenv@0, osenv@^0.1.0: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -osenv@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" - integrity sha1-Qv5tWVPfBsgGS+bxdsPQWqqjRkQ= - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - output-file-sync@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-2.0.1.tgz#f53118282f5f553c2799541792b723a4c71430c0" @@ -25191,6 +25158,13 @@ readdirp@~3.1.3: dependencies: picomatch "^2.0.4" +readdirp@~3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839" + integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ== + dependencies: + picomatch "^2.0.4" + readline2@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" @@ -26564,7 +26538,7 @@ sass-resources-loader@^2.0.1: glob "^7.1.1" loader-utils "^1.0.4" -sax@>=0.6.0, sax@^1.2.1, sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@^1.2.1, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -28627,19 +28601,6 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4: - version "4.4.8" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d" - integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.3.4" - minizlib "^1.1.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.2" - tcomb-validation@^3.3.0: version "3.4.1" resolved "https://registry.yarnpkg.com/tcomb-validation/-/tcomb-validation-3.4.1.tgz#a7696ec176ce56a081d9e019f8b732a5a8894b65" @@ -32114,10 +32075,10 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@13.1.1, yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1: - version "13.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0" - integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ== +yargs-parser@13.1.2, yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1, yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -32138,9 +32099,9 @@ yargs-parser@^11.1.1: decamelize "^1.2.0" yargs-parser@^15.0.0: - version "15.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.0.tgz#cdd7a97490ec836195f59f3f4dbe5ea9e8f75f08" - integrity sha512-xLTUnCMc4JhxrPEPUYD5IBR1mWCK/aT6+RJ/K29JY2y1vD+FhtgKK0AXRWvI262q3QSffAQuTouFIKUuHX89wQ== + version "15.0.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.1.tgz#54786af40b820dcb2fb8025b11b4d659d76323b3" + integrity sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -32211,10 +32172,10 @@ yargs@13.2.4: y18n "^4.0.0" yargs-parser "^13.1.0" -yargs@13.3.0, yargs@^13.2.2, yargs@^13.3.0: - version "13.3.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" - integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== +yargs@13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== dependencies: cliui "^5.0.0" find-up "^3.0.0" @@ -32225,7 +32186,7 @@ yargs@13.3.0, yargs@^13.2.2, yargs@^13.3.0: string-width "^3.0.0" which-module "^2.0.0" y18n "^4.0.0" - yargs-parser "^13.1.1" + yargs-parser "^13.1.2" yargs@4.8.1: version "4.8.1" @@ -32265,6 +32226,22 @@ yargs@^11.0.0: y18n "^3.2.1" yargs-parser "^9.0.2" +yargs@^13.2.2, yargs@^13.3.0: + version "13.3.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" + integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.1" + yargs@^14.2.0: version "14.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.0.tgz#f116a9242c4ed8668790b40759b4906c276e76c3" From 5abb2c8c7dbc6dfca05059261708f800ca1807bb Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 24 Mar 2020 08:31:29 +0100 Subject: [PATCH 15/64] Drilldowns (#59632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add drilldown wizard components * Dynamic actions (#58216) * feat: 🎸 add DynamicAction and FactoryAction types * feat: 🎸 add Mutable type to @kbn/utility-types * feat: 🎸 add ActionInternal and ActionContract * chore: 🤖 remove unused file * feat: 🎸 improve action interfaces * docs: ✏️ add JSDocs * feat: 🎸 simplify ui_actions interfaces * fix: 🐛 fix TypeScript types * feat: 🎸 add AbstractPresentable interface * feat: 🎸 add AbstractConfigurable interface * feat: 🎸 use AbstractPresentable in ActionInternal * test: 💍 fix ui_actions Jest tests * feat: 🎸 add state container to action * perf: ⚡️ convert MenuItem to React component on Action instance * refactor: 💡 rename AbsractPresentable -> Presentable * refactor: 💡 rename AbstractConfigurable -> Configurable * feat: 🎸 add Storybook to ui_actions * feat: 🎸 add component * feat: 🎸 improve component * chore: 🤖 use .story file extension prefix for Storybook * feat: 🎸 improve component * feat: 🎸 show error if dynamic action has CollectConfig missing * feat: 🎸 render sample action configuration component * feat: 🎸 connect action config to * feat: 🎸 improve stories * test: 💍 add ActionInternal serialize/deserialize tests * feat: 🎸 add ActionContract * feat: 🎸 split action Context into Execution and Presentation * fix: 🐛 fix TypeScript error * refactor: 💡 extract state container hooks to module scope * docs: ✏️ fix typos * chore: 🤖 remove Mutable type * test: 💍 don't cast to any getActions() function * style: 💄 avoid using unnecessary types * chore: 🤖 address PR review comments * chore: 🤖 rename ActionContext generic * chore: 🤖 remove order from state container * chore: 🤖 remove deprecation notice on getHref * test: 💍 fix tests after order field change * remove comments Co-authored-by: Matt Kime Co-authored-by: Elastic Machine * Drilldown context menu (#59638) * fix: 🐛 fix TypeScript error * feat: 🎸 add CONTEXT_MENU_DRILLDOWNS_TRIGGER trigger * fix: 🐛 correctly order context menu items * fix: 🐛 set correct order on drilldown flyout actions * fix: 🐛 clean up context menu building functions * feat: 🎸 add context menu separator action * Add basic ActionFactoryService. Pass data from it into components instead of mocks * Dashboard x pack (#59653) * feat: 🎸 add dashboard_enhanced plugin to x-pack * feat: 🎸 improve context menu separator * feat: 🎸 move drilldown flyout actions to dashboard_enhanced * fix: 🐛 fix exports from ui_actions plugin * feat: 🎸 "implement" registerDrilldown() method * fix ConfigurableBaseConfig type * Implement connected flyout_manage_drilldowns component * Simplify connected flyout manage drilldowns component. Remove intermediate component * clean up data-testid workaround in new components * Connect welcome message to storage Not sure, but use LocalStorage. Didn’t find a way to persist user settings. looks like uiSettings are not user scoped. * require `context` in Presentable. drill context down through wizard components * Drilldown factory (#59823) * refactor: 💡 import storage interface from ui_actions plugin * refactor: 💡 make actions not-dynamic * feat: 🎸 fix TypeScript errors, reshuffle types and code * fix: 🐛 fix more TypeScript errors * fix: 🐛 fix TypeScript import error * Drilldown registration (#59834) * feat: 🎸 improve drilldown registration method * fix: 🐛 set up translations for dashboard_enhanced plugin * Drilldown events 3 (#59854) * feat: 🎸 add serialize/unserialize to action * feat: 🎸 pass in uiActions service into Embeddable * feat: 🎸 merge ui_actions oss and basic plugins * refactor: 💡 move action factory registry to OSS * fix: 🐛 fix TypeScript errors * Drilldown events 4 (#59876) * feat: 🎸 mock sample drilldown execute methods * feat: 🎸 add .dynamicActions manager to Embeddable * feat: 🎸 add first version of dynamic action manager * Drilldown events 5 (#59885) * feat: 🎸 display drilldowns in context menu only on one embed * feat: 🎸 clear dynamic actions from registry when embed unloads * fix: 🐛 fix OSS TypeScript errors * basic integration of components with dynamicActionManager * fix: 🐛 don't overwrite explicitInput with combined input (#59938) * display drilldown count in embeddable edit mode * display drilldown count in embeddable edit mode * improve wizard components. more tests. * partial progress, dashboard drilldowns (#59977) * partial progress, dashboard drilldowns * partial progress, dashboard drilldowns * feat: 🎸 improve dashboard drilldown setup * feat: 🎸 wire in services into dashboard drilldown * chore: 🤖 add Storybook to dashboard_enhanced * feat: 🎸 create presentational * test: 💍 add stories * test: 💍 use presentation dashboar config component * feat: 🎸 wire in services into React component * docs: ✏️ add README to /components folder * feat: 🎸 increase importance of Dashboard drilldown * feat: 🎸 improve icon definition in drilldowns * chore: 🤖 remove unnecessary comment * chore: 🤖 add todos Co-authored-by: streamich * Manage drilldowns toasts. Add basic error handling. * support order in action factory selector * fix column order in manage drilldowns list * remove accidental debug info * bunch of nit ui fixes * Drilldowns reactive action manager (#60099) * feat: 🎸 improve isConfigValid return type * feat: 🎸 make DynamicActionManager reactive * docs: ✏️ add JSDocs to public mehtods of DynamicActionManager * feat: 🎸 make panel top-right corner number badge reactive * fix: 🐛 correctly await for .deleteEvents() * Drilldowns various 2 (#60103) * chore: 🤖 address review comments * test: 💍 fix embeddable_panel.test.tsx tests * chore: 🤖 clean up ActionInternal * chore: 🤖 make isConfigValid a simple predicate * chore: 🤖 fix TypeScript type errors * test: 💍 stub DynamicActionManager tests (#60104) * Drilldowns review 1 (#60139) * refactor: 💡 improve generic types * fix: 🐛 don't overwrite icon * fix: 🐛 fix x-pack TypeScript errors * fix: 🐛 fix TypeScript error * fix: 🐛 correct merge * Drilldowns various 4 (#60264) * feat: 🎸 hide "Create drilldown" from context menu when needed * style: 💄 remove AnyDrilldown type * feat: 🎸 add drilldown factory context * chore: 🤖 remove sample drilldown * fix: 🐛 increase spacing between action factory picker * workaround issue with closing flyout when navigating away Adds overlay just like other flyouts which makes this defect harder to bump in * fix react key issue in action_wizard * don’t open 2 flyouts * fix action order https://github.com/elastic/kibana/issues/60138 * Drilldowns reload stored (#60336) * style: 💄 don't use double equals __ * feat: 🎸 add reload$ to ActionStorage interface * feat: 🎸 add reload$ to embeddable event storage * feat: 🎸 add storage syncing to DynamicActionManager * refactor: 💡 use state from DynamicActionManager in React * fix: 🐛 add check for manager being stopped * Drilldowns triggers (#60339) * feat: 🎸 make use of supportedTriggers() * feat: 🎸 pass in context to configuration component * feat: 🎸 augment factory context * fix: 🐛 stop infinite re-rendering * Drilldowns multitrigger (#60357) * feat: 🎸 add support for multiple triggers * feat: 🎸 enable Drilldowns for TSVB Although TSVB brushing event is now broken on master, KibanaApp plans to fix it in 7.7 * "Create drilldown" flyout - design cleanup (#60309) * create drilldown flyout cleanup * remove border from selectedActionFactoryContainer * adjust callout in DrilldownHello * update form labels * remove unused file * fix type error Co-authored-by: Anton Dosov * basic unit tests for flyout_create_drildown action * Drilldowns finalize (#60371) * fix: 🐛 align flyout content to left side * fix: 🐛 move context menu item number 1px lower * fix: 🐛 move flyout back nav chevron up * fix: 🐛 fix type check after refactor * basic unit tests for drilldown actions * Drilldowns finalize 2 (#60510) * test: 💍 fix test mock * chore: 🤖 remove unused UiActionsService methods * refactor: 💡 cleanup UiActionsService action registration * fix: 🐛 add missing functionality after refactor * test: 💍 add action factory tests * test: 💍 add DynamicActionManager tests * feat: 🎸 capture error if it happens during initial load * fix: 🐛 register correctly CSV action * feat: 🎸 don't show "OPTIONS" title on drilldown context menus * feat: 🎸 add server-side for x-pack dashboard plugin * feat: 🎸 disable Drilldowns for TSVB * feat: 🎸 enable drilldowns on kibana.yml feature flag * feat: 🎸 add feature flag comment to kibana.yml * feat: 🎸 remove places from drilldown interface * refactor: 💡 remove place in factory context * chore: 🤖 remove doExecute * remove not needed now error_configure_action component * remove workaround for storybook * feat: 🎸 improve DrilldownDefinition interface * style: 💄 replace any by unknown * chore: 🤖 remove any * chore: 🤖 make isConfigValid return type a boolean * refactor: 💡 move getDisplayName to factory, remove deprecated * style: 💄 remove any * feat: 🎸 improve ActionFactoryDefinition * refactor: 💡 change visualize_embeddable params * feat: 🎸 add dashboard dependency to dashboard_enhanced * style: 💄 rename drilldown plugin life-cycle contracts * refactor: 💡 do naming adjustments for dashboard drilldown * fix: 🐛 fix Type error * fix: 🐛 fix TypeScript type errors * test: 💍 fix test after refactor * refactor: 💡 rename context -> placeContext in React component * chore: 🤖 remove setting from kibana.yml * refactor: 💡 change return type of getAction as per review * remove custom css per review * refactor: 💡 rename drilldownCount to eventCount * style: 💄 remove any * refactor: 💡 change how uiActions are passed to vis embeddable * style: 💄 remove unused import Co-authored-by: Anton Dosov Co-authored-by: Matt Kime Co-authored-by: Elastic Machine Co-authored-by: Andrea Del Rio --- .github/CODEOWNERS | 1 + examples/ui_action_examples/public/plugin.ts | 2 +- examples/ui_actions_explorer/public/app.tsx | 3 +- .../ui_actions_explorer/public/plugin.tsx | 16 +- .../public/overlays/flyout/flyout_service.tsx | 1 + src/dev/storybook/aliases.ts | 4 +- .../public/embeddable/visualize_embeddable.ts | 8 +- .../visualize_embeddable_factory.tsx | 10 +- .../public/np_ready/public/mocks.ts | 5 +- .../public/np_ready/public/plugin.ts | 6 +- .../public/actions/replace_panel_action.tsx | 2 +- src/plugins/dashboard/public/plugin.tsx | 4 +- .../public/tests/dashboard_container.test.tsx | 2 +- src/plugins/data/public/plugin.ts | 9 +- .../public/lib/actions/edit_panel_action.ts | 2 +- .../public/lib/embeddables/embeddable.tsx | 73 +- .../embeddable_action_storage.test.ts | 128 ++-- .../embeddables/embeddable_action_storage.ts | 53 +- .../public/lib/embeddables/i_embeddable.ts | 16 +- .../lib/panel/embeddable_panel.test.tsx | 14 +- .../public/lib/panel/embeddable_panel.tsx | 38 +- .../customize_title/customize_panel_action.ts | 8 +- .../panel_actions/inspect_panel_action.ts | 2 +- .../panel_actions/remove_panel_action.ts | 2 +- .../lib/panel/panel_header/panel_header.tsx | 9 +- .../create_state_container_react_helpers.ts | 69 +- .../common/state_containers/types.ts | 2 +- src/plugins/kibana_utils/index.ts | 20 + .../ui_actions/public/actions/action.ts | 26 +- .../public/actions/action_factory.ts | 71 ++ .../actions/action_factory_definition.ts | 46 ++ .../public/actions/action_internal.test.ts | 33 + .../public/actions/action_internal.ts | 58 ++ .../public/actions/create_action.ts | 14 +- .../actions/dynamic_action_manager.test.ts | 646 ++++++++++++++++++ .../public/actions/dynamic_action_manager.ts | 284 ++++++++ .../actions/dynamic_action_manager_state.ts | 111 +++ .../public/actions/dynamic_action_storage.ts | 102 +++ .../ui_actions/public/actions/index.ts | 6 + .../ui_actions/public/actions/types.ts | 24 + .../build_eui_context_menu_panels.tsx | 61 +- src/plugins/ui_actions/public/index.ts | 22 +- src/plugins/ui_actions/public/mocks.ts | 18 +- src/plugins/ui_actions/public/plugin.ts | 8 +- .../public/service/ui_actions_service.test.ts | 114 +++- .../public/service/ui_actions_service.ts | 122 +++- .../tests/execute_trigger_actions.test.ts | 10 +- .../public/tests/get_trigger_actions.test.ts | 9 +- .../get_trigger_compatible_actions.test.ts | 6 +- .../public/tests/test_samples/index.ts | 1 + .../public/triggers/select_range_trigger.ts | 2 +- .../public/triggers/trigger_internal.ts | 1 + .../public/triggers/value_click_trigger.ts | 2 +- src/plugins/ui_actions/public/types.ts | 6 +- .../ui_actions/public/util/configurable.ts | 60 ++ src/plugins/ui_actions/public/util/index.ts | 21 + .../presentable.ts} | 47 +- src/plugins/ui_actions/scripts/storybook.js | 26 + .../public/np_ready/public/plugin.tsx | 3 +- .../public/sample_panel_action.tsx | 3 +- .../public/sample_panel_link.ts | 3 +- x-pack/.i18nrc.json | 1 + .../action_wizard/action_wizard.scss | 5 - .../action_wizard/action_wizard.story.tsx | 24 +- .../action_wizard/action_wizard.test.tsx | 13 +- .../action_wizard/action_wizard.tsx | 95 ++- .../public/components/action_wizard/i18n.ts | 2 +- .../public/components/action_wizard/index.ts | 2 +- .../components/action_wizard/test_data.tsx | 218 +++--- .../public/components/index.ts} | 2 +- .../public/custom_time_range_action.tsx | 2 +- .../advanced_ui_actions/public/index.ts | 14 + .../advanced_ui_actions/public/plugin.ts | 28 +- .../action_factory_service/action_factory.ts | 11 + .../action_factory_definition.ts | 11 + .../services/action_factory_service/index.ts | 8 + .../public/services}/index.ts | 2 +- .../advanced_ui_actions/public/util/index.ts | 10 + x-pack/plugins/dashboard_enhanced/README.md | 1 + x-pack/plugins/dashboard_enhanced/kibana.json | 8 + .../public/components/README.md | 5 + .../dashboard_drilldown_config.story.tsx | 54 ++ .../dashboard_drilldown_config.test.tsx | 11 + .../dashboard_drilldown_config.tsx | 69 ++ .../dashboard_drilldown_config/i18n.ts | 14 + .../dashboard_drilldown_config/index.ts | 7 + .../public/components/index.ts | 7 + .../dashboard_enhanced/public/index.ts | 19 + .../dashboard_enhanced/public/mocks.ts | 27 + .../dashboard_enhanced/public/plugin.ts | 50 ++ .../flyout_create_drilldown.test.tsx | 124 ++++ .../flyout_create_drilldown.tsx | 74 ++ .../actions/flyout_create_drilldown/index.ts | 11 + .../flyout_edit_drilldown.test.tsx | 102 +++ .../flyout_edit_drilldown.tsx | 71 ++ .../actions/flyout_edit_drilldown}/i18n.ts | 6 +- .../actions/flyout_edit_drilldown/index.tsx | 11 + .../flyout_edit_drilldown/menu_item.test.tsx | 37 + .../flyout_edit_drilldown/menu_item.tsx | 30 + .../services/drilldowns}/actions/index.ts | 0 .../drilldowns/actions/test_helpers.ts | 28 + .../dashboard_drilldowns_services.ts | 60 ++ .../collect_config.test.tsx | 9 + .../collect_config.tsx | 55 ++ .../constants.ts | 7 + .../drilldown.test.tsx | 20 + .../drilldown.tsx | 52 ++ .../dashboard_to_dashboard_drilldown/i18n.ts | 11 + .../dashboard_to_dashboard_drilldown/index.ts | 16 + .../dashboard_to_dashboard_drilldown/types.ts | 22 + .../public/services/drilldowns/index.ts | 7 + .../public/services/index.ts | 7 + .../scripts/storybook.js} | 10 +- .../dashboard_enhanced/server/config.ts | 23 + .../dashboard_enhanced/server/index.ts | 12 + x-pack/plugins/drilldowns/kibana.json | 5 +- .../actions/flyout_create_drilldown/index.tsx | 52 -- .../actions/flyout_edit_drilldown/index.tsx | 72 -- ...nnected_flyout_manage_drilldowns.story.tsx | 43 ++ ...onnected_flyout_manage_drilldowns.test.tsx | 221 ++++++ .../connected_flyout_manage_drilldowns.tsx | 332 +++++++++ .../i18n.ts | 88 +++ .../index.ts | 7 + .../test_data.ts | 89 +++ .../drilldown_hello_bar.story.tsx | 16 +- .../drilldown_hello_bar.tsx | 58 +- .../components/drilldown_hello_bar/i18n.ts | 29 + .../drilldown_picker/drilldown_picker.tsx | 21 - .../flyout_create_drilldown.story.tsx | 24 - .../flyout_create_drilldown.tsx | 34 - .../flyout_drilldown_wizard.story.tsx | 70 ++ .../flyout_drilldown_wizard.tsx | 139 ++++ .../flyout_drilldown_wizard/i18n.ts | 42 ++ .../flyout_drilldown_wizard/index.ts | 7 + .../flyout_frame/flyout_frame.story.tsx | 7 + .../flyout_frame/flyout_frame.test.tsx | 4 +- .../components/flyout_frame/flyout_frame.tsx | 31 +- .../public/components/flyout_frame/i18n.ts | 6 +- .../flyout_list_manage_drilldowns.story.tsx | 22 + .../flyout_list_manage_drilldowns.tsx | 46 ++ .../flyout_list_manage_drilldowns/i18n.ts | 14 + .../flyout_list_manage_drilldowns/index.ts | 7 + .../form_create_drilldown.story.tsx | 34 - .../form_create_drilldown.tsx | 52 -- .../form_drilldown_wizard.story.tsx | 29 + .../form_drilldown_wizard.test.tsx} | 20 +- .../form_drilldown_wizard.tsx | 79 +++ .../i18n.ts | 4 +- .../index.tsx | 2 +- .../components/list_manage_drilldowns/i18n.ts | 36 + .../list_manage_drilldowns/index.tsx | 7 + .../list_manage_drilldowns.story.tsx | 19 + .../list_manage_drilldowns.test.tsx | 70 ++ .../list_manage_drilldowns.tsx | 116 ++++ x-pack/plugins/drilldowns/public/index.ts | 10 +- x-pack/plugins/drilldowns/public/mocks.ts | 12 +- x-pack/plugins/drilldowns/public/plugin.ts | 54 +- .../public/service/drilldown_service.ts | 32 - .../public/services/drilldown_service.ts | 79 +++ .../public/{service => services}/index.ts | 0 x-pack/plugins/drilldowns/public/types.ts | 120 ++++ x-pack/plugins/reporting/public/plugin.tsx | 3 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 164 files changed, 5368 insertions(+), 898 deletions(-) create mode 100644 src/plugins/kibana_utils/index.ts create mode 100644 src/plugins/ui_actions/public/actions/action_factory.ts create mode 100644 src/plugins/ui_actions/public/actions/action_factory_definition.ts create mode 100644 src/plugins/ui_actions/public/actions/action_internal.test.ts create mode 100644 src/plugins/ui_actions/public/actions/action_internal.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_storage.ts create mode 100644 src/plugins/ui_actions/public/actions/types.ts create mode 100644 src/plugins/ui_actions/public/util/configurable.ts create mode 100644 src/plugins/ui_actions/public/util/index.ts rename src/plugins/ui_actions/public/{actions/action_definition.ts => util/presentable.ts} (50%) create mode 100644 src/plugins/ui_actions/scripts/storybook.js rename x-pack/plugins/{drilldowns/public/components/drilldown_picker/index.tsx => advanced_ui_actions/public/components/index.ts} (87%) create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts rename x-pack/plugins/{drilldowns/public/components/flyout_create_drilldown => advanced_ui_actions/public/services}/index.ts (84%) create mode 100644 x-pack/plugins/advanced_ui_actions/public/util/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/README.md create mode 100644 x-pack/plugins/dashboard_enhanced/kibana.json create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/README.md create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/mocks.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/plugin.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx rename x-pack/plugins/{drilldowns/public/components/flyout_create_drilldown => dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown}/i18n.ts (64%) create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx rename x-pack/plugins/{drilldowns/public => dashboard_enhanced/public/services/drilldowns}/actions/index.ts (100%) create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/index.ts rename x-pack/plugins/{drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx => dashboard_enhanced/scripts/storybook.js} (53%) create mode 100644 x-pack/plugins/dashboard_enhanced/server/config.ts create mode 100644 x-pack/plugins/dashboard_enhanced/server/index.ts delete mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx delete mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts create mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown/form_create_drilldown.test.tsx => form_drilldown_wizard/form_drilldown_wizard.test.tsx} (70%) create mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown => form_drilldown_wizard}/i18n.ts (89%) rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown => form_drilldown_wizard}/index.tsx (85%) create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx delete mode 100644 x-pack/plugins/drilldowns/public/service/drilldown_service.ts create mode 100644 x-pack/plugins/drilldowns/public/services/drilldown_service.ts rename x-pack/plugins/drilldowns/public/{service => services}/index.ts (100%) create mode 100644 x-pack/plugins/drilldowns/public/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2db898fab68bf..d48b29c89ece6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,6 +3,7 @@ # For more info, see https://help.github.com/articles/about-codeowners/ # App +/x-pack/legacy/plugins/dashboard_enhanced/ @elastic/kibana-app /x-pack/legacy/plugins/lens/ @elastic/kibana-app /x-pack/legacy/plugins/graph/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index c47746d4b3fd6..d053f7e82862c 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -46,7 +46,7 @@ export class UiActionExamplesPlugin })); uiActions.registerAction(helloWorldAction); - uiActions.attachAction(helloWorldTrigger.id, helloWorldAction); + uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction); } public start() {} diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index 462f5c3bf88ba..f08b8bb29bdd3 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -95,8 +95,7 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { ); }, }); - uiActionsApi.registerAction(dynamicAction); - uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); + uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); setConfirmationText( `You've successfully added a new action: ${dynamicAction.getDisplayName( {} diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index f1895905a45e1..de86b51aee3a8 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin (await startServices)[1].uiActions) ); - deps.uiActions.attachAction( + deps.uiActions.addTriggerAction( USER_TRIGGER, createEditUserAction(async () => (await startServices)[0].overlays.openModal) ); - deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction); - deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction); - deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability); - deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction); - deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability); - deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction); + deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability); core.application.register({ id: 'uiActionsExplorer', diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index b609b2ce1d741..444430175d4f2 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -91,6 +91,7 @@ export interface OverlayFlyoutStart { export interface OverlayFlyoutOpenOptions { className?: string; closeButtonAriaLabel?: string; + ownFocus?: boolean; 'data-test-subj'?: string; } diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 8ed64f004c9be..370abc120d475 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,12 +18,14 @@ */ export const storybookAliases = { + advanced_ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', apm: 'x-pack/legacy/plugins/apm/scripts/storybook.js', canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js', codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts', + dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/scripts/storybook.js', drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js', embeddable: 'src/plugins/embeddable/scripts/storybook.js', infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js', siem: 'x-pack/legacy/plugins/siem/scripts/storybook.js', - ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', + ui_actions: 'src/plugins/ui_actions/scripts/storybook.js', }; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts index 342824bade3dd..4b21be83f1722 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts @@ -45,6 +45,7 @@ import { PersistedState } from '../../../../../../../plugins/visualizations/publ import { buildPipeline } from '../legacy/build_pipeline'; import { Vis } from '../vis'; import { getExpressions, getUiActions } from '../services'; +import { VisualizationsStartDeps } from '../plugin'; import { VIS_EVENT_TO_TRIGGER } from './events'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -56,6 +57,7 @@ export interface VisualizeEmbeddableConfiguration { editable: boolean; appState?: { save(): void }; uiState?: PersistedState; + uiActions?: VisualizationsStartDeps['uiActions']; } export interface VisualizeInput extends EmbeddableInput { @@ -94,7 +96,7 @@ export class VisualizeEmbeddable extends Embeddable { public readonly type = VISUALIZE_EMBEDDABLE_TYPE; - constructor() { + constructor( + private readonly getUiActions: () => Promise< + Pick['uiActions'] + > + ) { super({ savedObjectMetaData: { name: i18n.translate('visualizations.savedObjectName', { defaultMessage: 'Visualization' }), @@ -114,6 +119,8 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< const indexPattern = vis.data.indexPattern; const indexPatterns = indexPattern ? [indexPattern] : []; + const uiActions = await this.getUiActions(); + const editable = await this.isEditable(); return new VisualizeEmbeddable( getTimeFilter(), @@ -124,6 +131,7 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< editable, appState: input.appState, uiState: input.uiState, + uiActions, }, input, parent diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts index 17f777e4e80e1..dcd11c920f17c 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PluginInitializerContext } from '../../../../../../core/public'; +import { CoreSetup, PluginInitializerContext } from '../../../../../../core/public'; import { VisualizationsSetup, VisualizationsStart } from './'; import { VisualizationsPlugin } from './plugin'; import { coreMock } from '../../../../../../core/public/mocks'; @@ -26,6 +26,7 @@ import { expressionsPluginMock } from '../../../../../../plugins/expressions/pub import { dataPluginMock } from '../../../../../../plugins/data/public/mocks'; import { usageCollectionPluginMock } from '../../../../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../../../../plugins/ui_actions/public/mocks'; +import { VisualizationsStartDeps } from './plugin'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -48,7 +49,7 @@ const createStartContract = (): VisualizationsStart => ({ const createInstance = async () => { const plugin = new VisualizationsPlugin({} as PluginInitializerContext); - const setup = plugin.setup(coreMock.createSetup(), { + const setup = plugin.setup(coreMock.createSetup() as CoreSetup, { data: dataPluginMock.createSetupContract(), expressions: expressionsPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createSetupContract(), diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts index 3ade6cee0d4d2..c826841e2bcf3 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts @@ -111,7 +111,7 @@ export class VisualizationsPlugin constructor(initializerContext: PluginInitializerContext) {} public setup( - core: CoreSetup, + core: CoreSetup, { expressions, embeddable, usageCollection, data }: VisualizationsSetupDeps ): VisualizationsSetup { setUISettings(core.uiSettings); @@ -120,7 +120,9 @@ export class VisualizationsPlugin expressions.registerFunction(visualizationFunction); expressions.registerRenderer(visualizationRenderer); - const embeddableFactory = new VisualizeEmbeddableFactory(); + const embeddableFactory = new VisualizeEmbeddableFactory( + async () => (await core.getStartServices())[1].uiActions + ); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); return { diff --git a/src/plugins/dashboard/public/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/actions/replace_panel_action.tsx index 21ec961917d17..4e20aa3c35088 100644 --- a/src/plugins/dashboard/public/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/actions/replace_panel_action.tsx @@ -37,7 +37,7 @@ export interface ReplacePanelActionContext { export class ReplacePanelAction implements ActionByType { public readonly type = ACTION_REPLACE_PANEL; public readonly id = ACTION_REPLACE_PANEL; - public order = 11; + public order = 3; constructor( private core: CoreStart, diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 8a6e747aac170..d663c736e5aed 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -78,7 +78,7 @@ export class DashboardEmbeddableContainerPublicPlugin ): Setup { const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); const startServices = core.getStartServices(); if (share) { @@ -134,7 +134,7 @@ export class DashboardEmbeddableContainerPublicPlugin plugins.embeddable.getEmbeddableFactories ); uiActions.registerAction(changeViewAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, changeViewAction); } public stop() {} diff --git a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx index a81d80b440e04..4aede3f3442fb 100644 --- a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx @@ -49,7 +49,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const editModeAction = createEditModeAction(); uiActionsSetup.registerAction(editModeAction); - uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction); + uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); setup.registerEmbeddableFactory( CONTACT_CARD_EMBEDDABLE, new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any) diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index fc5dde94fa851..ea2e85947aa12 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -109,12 +109,12 @@ export class DataPublicPlugin implements Plugin { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; - public order = 15; + public order = 50; constructor(private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']) {} diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index eb10c16806640..35973cc16cf9b 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -16,23 +16,35 @@ * specific language governing permissions and limitations * under the License. */ -import { isEqual, cloneDeep } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import * as Rx from 'rxjs'; -import { Adapters } from '../types'; +import { Adapters, ViewMode } from '../types'; import { IContainer } from '../containers'; -import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable'; -import { ViewMode } from '../types'; +import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { TriggerContextMapping } from '../ui_actions'; import { EmbeddableActionStorage } from './embeddable_action_storage'; +import { + UiActionsDynamicActionManager, + UiActionsStart, +} from '../../../../../plugins/ui_actions/public'; +import { EmbeddableContext } from '../triggers'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title; } +export interface EmbeddableParams { + uiActions?: UiActionsStart; +} + export abstract class Embeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput > implements IEmbeddable { + static runtimeId: number = 0; + + public readonly runtimeId = Embeddable.runtimeId++; + public readonly parent?: IContainer; public readonly isContainer: boolean = false; public abstract readonly type: string; @@ -48,15 +60,34 @@ export abstract class Embeddable< // to update input when the parent changes. private parentSubscription?: Rx.Subscription; + private storageSubscription?: Rx.Subscription; + // TODO: Rename to destroyed. private destoyed: boolean = false; - private __actionStorage?: EmbeddableActionStorage; - public get actionStorage(): EmbeddableActionStorage { - return this.__actionStorage || (this.__actionStorage = new EmbeddableActionStorage(this)); + private storage = new EmbeddableActionStorage((this as unknown) as Embeddable); + + private cachedDynamicActions?: UiActionsDynamicActionManager; + public get dynamicActions(): UiActionsDynamicActionManager | undefined { + if (!this.params.uiActions) return undefined; + if (!this.cachedDynamicActions) { + this.cachedDynamicActions = new UiActionsDynamicActionManager({ + isCompatible: async (context: unknown) => + (context as EmbeddableContext).embeddable.runtimeId === this.runtimeId, + storage: this.storage, + uiActions: this.params.uiActions, + }); + } + + return this.cachedDynamicActions; } - constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) { + constructor( + input: TEmbeddableInput, + output: TEmbeddableOutput, + parent?: IContainer, + public readonly params: EmbeddableParams = {} + ) { this.id = input.id; this.output = { title: getPanelTitle(input, output), @@ -80,6 +111,18 @@ export abstract class Embeddable< this.onResetInput(newInput); }); } + + if (this.dynamicActions) { + this.dynamicActions.start().catch(error => { + /* eslint-disable */ + console.log('Failed to start embeddable dynamic actions', this); + console.error(error); + /* eslint-enable */ + }); + this.storageSubscription = this.input$.subscribe(() => { + this.storage.reload$.next(); + }); + } } public getIsContainer(): this is IContainer { @@ -158,6 +201,20 @@ export abstract class Embeddable< */ public destroy(): void { this.destoyed = true; + + if (this.dynamicActions) { + this.dynamicActions.stop().catch(error => { + /* eslint-disable */ + console.log('Failed to stop embeddable dynamic actions', this); + console.error(error); + /* eslint-enable */ + }); + } + + if (this.storageSubscription) { + this.storageSubscription.unsubscribe(); + } + if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts index 56facc37fc666..83fd3f184e098 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts @@ -20,7 +20,8 @@ import { Embeddable } from './embeddable'; import { EmbeddableInput } from './i_embeddable'; import { ViewMode } from '../types'; -import { EmbeddableActionStorage, SerializedEvent } from './embeddable_action_storage'; +import { EmbeddableActionStorage } from './embeddable_action_storage'; +import { UiActionsSerializedEvent } from '../../../../ui_actions/public'; import { of } from '../../../../kibana_utils/common'; class TestEmbeddable extends Embeddable { @@ -42,9 +43,9 @@ describe('EmbeddableActionStorage', () => { test('can add event to embeddable', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -57,23 +58,40 @@ describe('EmbeddableActionStorage', () => { expect(events2).toEqual([event]); }); + test('does not merge .getInput() into .updateInput()', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + const event: UiActionsSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + const spy = jest.spyOn(embeddable, 'updateInput'); + + await storage.create(event); + + expect(spy.mock.calls[0][0].id).toBe(undefined); + expect(spy.mock.calls[0][0].viewMode).toBe(undefined); + }); + test('can create multiple events', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -95,9 +113,9 @@ describe('EmbeddableActionStorage', () => { test('throws when creating an event with the same ID', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -122,16 +140,16 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, @@ -148,30 +166,30 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event22: SerializedEvent = { + const event22: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'baz', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -199,9 +217,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -217,14 +235,14 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -249,9 +267,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -266,23 +284,23 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -327,9 +345,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -355,9 +373,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -383,9 +401,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -402,19 +420,19 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID2', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID3', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -458,7 +476,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -466,7 +484,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -502,15 +520,15 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts index 520f92840c5f9..fad5b4d535d6c 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts @@ -17,32 +17,20 @@ * under the License. */ +import { + UiActionsAbstractActionStorage, + UiActionsSerializedEvent, +} from '../../../../ui_actions/public'; import { Embeddable } from '..'; -/** - * Below two interfaces are here temporarily, they will move to `ui_actions` - * plugin once #58216 is merged. - */ -export interface SerializedEvent { - eventId: string; - triggerId: string; - action: unknown; -} -export interface ActionStorage { - create(event: SerializedEvent): Promise; - update(event: SerializedEvent): Promise; - remove(eventId: string): Promise; - read(eventId: string): Promise; - count(): Promise; - list(): Promise; -} - -export class EmbeddableActionStorage implements ActionStorage { - constructor(private readonly embbeddable: Embeddable) {} +export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { + constructor(private readonly embbeddable: Embeddable) { + super(); + } - async create(event: SerializedEvent) { + async create(event: UiActionsSerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const exists = !!events.find(({ eventId }) => eventId === event.eventId); if (exists) { @@ -53,14 +41,13 @@ export class EmbeddableActionStorage implements ActionStorage { } this.embbeddable.updateInput({ - ...input, events: [...events, event], }); } - async update(event: SerializedEvent) { + async update(event: UiActionsSerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const index = events.findIndex(({ eventId }) => eventId === event.eventId); if (index === -1) { @@ -72,14 +59,13 @@ export class EmbeddableActionStorage implements ActionStorage { } this.embbeddable.updateInput({ - ...input, events: [...events.slice(0, index), event, ...events.slice(index + 1)], }); } async remove(eventId: string) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const index = events.findIndex(event => eventId === event.eventId); if (index === -1) { @@ -91,14 +77,13 @@ export class EmbeddableActionStorage implements ActionStorage { } this.embbeddable.updateInput({ - ...input, events: [...events.slice(0, index), ...events.slice(index + 1)], }); } - async read(eventId: string): Promise { + async read(eventId: string): Promise { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const event = events.find(ev => eventId === ev.eventId); if (!event) { @@ -113,14 +98,10 @@ export class EmbeddableActionStorage implements ActionStorage { private __list() { const input = this.embbeddable.getInput(); - return (input.events || []) as SerializedEvent[]; - } - - async count(): Promise { - return this.__list().length; + return (input.events || []) as UiActionsSerializedEvent[]; } - async list(): Promise { + async list(): Promise { return this.__list(); } } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 6345c34b0dda2..9a4452aceba00 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -18,6 +18,7 @@ */ import { Observable } from 'rxjs'; +import { UiActionsDynamicActionManager } from '../../../../../plugins/ui_actions/public'; import { Adapters } from '../types'; import { IContainer } from '../containers/i_container'; import { ViewMode } from '../types'; @@ -33,7 +34,7 @@ export interface EmbeddableInput { /** * Reserved key for `ui_actions` events. */ - events?: unknown; + events?: Array<{ eventId: string }>; /** * List of action IDs that this embeddable should not render. @@ -82,6 +83,19 @@ export interface IEmbeddable< **/ readonly id: string; + /** + * Unique ID an embeddable is assigned each time it is initialized. This ID + * is different for different instances of the same embeddable. For example, + * if the same dashboard is rendered twice on the screen, all embeddable + * instances will have a unique `runtimeId`. + */ + readonly runtimeId?: number; + + /** + * Default implementation of dynamic action API for embeddables. + */ + dynamicActions?: UiActionsDynamicActionManager; + /** * A functional representation of the isContainer variable, but helpful for typescript to * know the shape if this returns true diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 757d4e6bfddef..83d3d5e10761b 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -44,7 +44,7 @@ import { import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; -const actionRegistry = new Map>(); +const actionRegistry = new Map(); const triggerRegistry = new Map(); const embeddableFactories = new Map(); const getEmbeddableFactory = (id: string) => embeddableFactories.get(id); @@ -213,13 +213,17 @@ const renderInEditModeAndOpenContextMenu = async ( }; test('HelloWorldContainer in edit mode hides disabledActions', async () => { - const action: Action = { + const action = { id: 'FOO', type: 'FOO' as ActionType, getIconType: () => undefined, getDisplayName: () => 'foo', isCompatible: async () => true, execute: async () => {}, + order: 10, + getHref: () => { + return undefined; + }, }; const getActions = () => Promise.resolve([action]); @@ -245,13 +249,17 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => { }); test('HelloWorldContainer hides disabled badges', async () => { - const action: Action = { + const action = { id: 'BAR', type: 'BAR' as ActionType, getIconType: () => undefined, getDisplayName: () => 'bar', isCompatible: async () => true, execute: async () => {}, + order: 10, + getHref: () => { + return undefined; + }, }; const getActions = () => Promise.resolve([action]); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index b95060a73252f..c6537f2d94994 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -38,6 +38,14 @@ import { EditPanelAction } from '../actions'; import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal'; import { EmbeddableStart } from '../../plugin'; +const sortByOrderField = ( + { order: orderA }: { order?: number }, + { order: orderB }: { order?: number } +) => (orderB || 0) - (orderA || 0); + +const removeById = (disabledActions: string[]) => ({ id }: { id: string }) => + disabledActions.indexOf(id) === -1; + interface Props { embeddable: IEmbeddable; getActions: UiActionsService['getTriggerCompatibleActions']; @@ -57,12 +65,14 @@ interface State { hidePanelTitles: boolean; closeContextMenu: boolean; badges: Array>; + eventCount?: number; } export class EmbeddablePanel extends React.Component { private embeddableRoot: React.RefObject; private parentSubscription?: Subscription; private subscription?: Subscription; + private eventCountSubscription?: Subscription; private mounted: boolean = false; private generateId = htmlIdGenerator(); @@ -136,6 +146,9 @@ export class EmbeddablePanel extends React.Component { if (this.subscription) { this.subscription.unsubscribe(); } + if (this.eventCountSubscription) { + this.eventCountSubscription.unsubscribe(); + } if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } @@ -177,6 +190,7 @@ export class EmbeddablePanel extends React.Component { badges={this.state.badges} embeddable={this.props.embeddable} headerId={headerId} + eventCount={this.state.eventCount} /> )}
@@ -188,6 +202,15 @@ export class EmbeddablePanel extends React.Component { if (this.embeddableRoot.current) { this.props.embeddable.render(this.embeddableRoot.current); } + + const dynamicActions = this.props.embeddable.dynamicActions; + if (dynamicActions) { + this.setState({ eventCount: dynamicActions.state.get().events.length }); + this.eventCountSubscription = dynamicActions.state.state$.subscribe(({ events }) => { + if (!this.mounted) return; + this.setState({ eventCount: events.length }); + }); + } } closeMyContextMenuPanel = () => { @@ -201,13 +224,14 @@ export class EmbeddablePanel extends React.Component { }; private getActionContextMenuPanel = async () => { - let actions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { + let regularActions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { embeddable: this.props.embeddable, }); const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { - actions = actions.filter(action => disabledActions.indexOf(action.id) === -1); + const removeDisabledActions = removeById(disabledActions); + regularActions = regularActions.filter(removeDisabledActions); } const createGetUserData = (overlays: OverlayStart) => @@ -246,16 +270,10 @@ export class EmbeddablePanel extends React.Component { new EditPanelAction(this.props.getEmbeddableFactory), ]; - const sorted = actions - .concat(extraActions) - .sort((a: Action, b: Action) => { - const bOrder = b.order || 0; - const aOrder = a.order || 0; - return bOrder - aOrder; - }); + const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); return await buildContextMenuForActions({ - actions: sorted, + actions: sortedActions, actionContext: { embeddable: this.props.embeddable }, closeMenu: this.closeMyContextMenuPanel, }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts index c0e43c0538833..36957c3b79491 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts @@ -33,15 +33,13 @@ interface ActionContext { export class CustomizePanelTitleAction implements Action { public readonly type = ACTION_CUSTOMIZE_PANEL; public id = ACTION_CUSTOMIZE_PANEL; - public order = 10; + public order = 40; - constructor(private readonly getDataFromUser: GetUserData) { - this.order = 10; - } + constructor(private readonly getDataFromUser: GetUserData) {} public getDisplayName() { return i18n.translate('embeddableApi.customizePanel.action.displayName', { - defaultMessage: 'Customize panel', + defaultMessage: 'Edit panel title', }); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts index d04f35715537c..ae9645767b267 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts @@ -31,7 +31,7 @@ interface ActionContext { export class InspectPanelAction implements Action { public readonly type = ACTION_INSPECT_PANEL; public readonly id = ACTION_INSPECT_PANEL; - public order = 10; + public order = 20; constructor(private readonly inspector: InspectorStartContract) {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts index ee7948f3d6a4a..a6d4128f3f106 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts @@ -41,7 +41,7 @@ function hasExpandedPanelInput( export class RemovePanelAction implements Action { public readonly type = REMOVE_PANEL_ACTION; public readonly id = REMOVE_PANEL_ACTION; - public order = 5; + public order = 1; constructor() {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 99516a1d21d6f..2a856af7ae916 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -23,6 +23,7 @@ import { EuiIcon, EuiToolTip, EuiScreenReaderOnly, + EuiNotificationBadge, } from '@elastic/eui'; import classNames from 'classnames'; import React from 'react'; @@ -40,6 +41,7 @@ export interface PanelHeaderProps { badges: Array>; embeddable: IEmbeddable; headerId?: string; + eventCount?: number; } function renderBadges(badges: Array>, embeddable: IEmbeddable) { @@ -90,6 +92,7 @@ export function PanelHeader({ badges, embeddable, headerId, + eventCount, }: PanelHeaderProps) { const viewDescription = getViewDescription(embeddable); const showTitle = !isViewMode || (title && !hidePanelTitles) || viewDescription !== ''; @@ -147,7 +150,11 @@ export function PanelHeader({ )} {renderBadges(badges, embeddable)} - + {!isViewMode && !!eventCount && ( + + {eventCount} + + )} >( + container: Container +): UnboxState => useObservable(container.state$, container.get()); + +/** + * Apply selector to state container to extract only needed information. Will + * re-render your component only when the section changes. + * + * @param container State container which state to track. + * @param selector Function used to pick parts of state. + * @param comparator Comparator function used to memoize previous result, to not + * re-render React component if state did not change. By default uses + * `fast-deep-equal` package. + */ +export const useContainerSelector = , Result>( + container: Container, + selector: (state: UnboxState) => Result, + comparator: Comparator = defaultComparator +): Result => { + const { state$, get } = container; + const lastValueRef = useRef(get()); + const [value, setValue] = React.useState(() => { + const newValue = selector(get()); + lastValueRef.current = newValue; + return newValue; + }); + useLayoutEffect(() => { + const subscription = state$.subscribe((currentState: UnboxState) => { + const newValue = selector(currentState); + if (!comparator(lastValueRef.current, newValue)) { + lastValueRef.current = newValue; + setValue(newValue); + } + }); + return () => subscription.unsubscribe(); + }, [state$, comparator]); + return value; +}; + export const createStateContainerReactHelpers = >() => { const context = React.createContext(null as any); const useContainer = (): Container => useContext(context); const useState = (): UnboxState => { - const { state$, get } = useContainer(); - const value = useObservable(state$, get()); - return value; + const container = useContainer(); + return useContainerState(container); }; const useTransitions: () => Container['transitions'] = () => useContainer().transitions; @@ -41,24 +84,8 @@ export const createStateContainerReactHelpers = ) => Result, comparator: Comparator = defaultComparator ): Result => { - const { state$, get } = useContainer(); - const lastValueRef = useRef(get()); - const [value, setValue] = React.useState(() => { - const newValue = selector(get()); - lastValueRef.current = newValue; - return newValue; - }); - useLayoutEffect(() => { - const subscription = state$.subscribe((currentState: UnboxState) => { - const newValue = selector(currentState); - if (!comparator(lastValueRef.current, newValue)) { - lastValueRef.current = newValue; - setValue(newValue); - } - }); - return () => subscription.unsubscribe(); - }, [state$, comparator]); - return value; + const container = useContainer(); + return useContainerSelector(container, selector, comparator); }; const connect: Connect> = mapStateToProp => component => props => diff --git a/src/plugins/kibana_utils/common/state_containers/types.ts b/src/plugins/kibana_utils/common/state_containers/types.ts index 26a29bc470e8a..29ffa4cd486b5 100644 --- a/src/plugins/kibana_utils/common/state_containers/types.ts +++ b/src/plugins/kibana_utils/common/state_containers/types.ts @@ -43,7 +43,7 @@ export interface BaseStateContainer { export interface StateContainer< State extends BaseState, - PureTransitions extends object, + PureTransitions extends object = object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; diff --git a/src/plugins/kibana_utils/index.ts b/src/plugins/kibana_utils/index.ts new file mode 100644 index 0000000000000..14d6e52dc0465 --- /dev/null +++ b/src/plugins/kibana_utils/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { createStateContainer, StateContainer, of } from './common'; diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index 2b2fc004a84c6..15f1d6dd79289 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -19,10 +19,12 @@ import { UiComponent } from 'src/plugins/kibana_utils/common'; import { ActionType, ActionContextMapping } from '../types'; +import { Presentable } from '../util/presentable'; export type ActionByType = Action; -export interface Action { +export interface Action + extends Partial> { /** * Determined the order when there is more than one action matched to a trigger. * Higher numbers are displayed first. @@ -63,12 +65,30 @@ export interface Action { isCompatible(context: Context): Promise; /** - * If this returns something truthy, this is used in addition to the `execute` method when clicked. + * Executes the action. */ - getHref?(context: Context): string | undefined; + execute(context: Context): Promise; +} + +/** + * A convenience interface used to register an action. + */ +export interface ActionDefinition + extends Partial> { + /** + * ID of the action that uniquely identifies this action in the actions registry. + */ + readonly id: string; + + /** + * ID of the factory for this action. Used to construct dynamic actions. + */ + readonly type?: ActionType; /** * Executes the action. */ execute(context: Context): Promise; } + +export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/actions/action_factory.ts b/src/plugins/ui_actions/public/actions/action_factory.ts new file mode 100644 index 0000000000000..bc0ec844d00f5 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_factory.ts @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { uiToReactComponent } from '../../../kibana_react/public'; +import { Presentable } from '../util/presentable'; +import { ActionDefinition } from './action'; +import { ActionFactoryDefinition } from './action_factory_definition'; +import { Configurable } from '../util'; +import { SerializedAction } from './types'; + +export class ActionFactory< + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object +> implements Presentable, Configurable { + constructor( + protected readonly def: ActionFactoryDefinition + ) {} + + public readonly id = this.def.id; + public readonly order = this.def.order || 0; + public readonly MenuItem? = this.def.MenuItem; + public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; + + public readonly CollectConfig = this.def.CollectConfig; + public readonly ReactCollectConfig = uiToReactComponent(this.CollectConfig); + public readonly createConfig = this.def.createConfig; + public readonly isConfigValid = this.def.isConfigValid; + + public getIconType(context: FactoryContext): string | undefined { + if (!this.def.getIconType) return undefined; + return this.def.getIconType(context); + } + + public getDisplayName(context: FactoryContext): string { + if (!this.def.getDisplayName) return ''; + return this.def.getDisplayName(context); + } + + public async isCompatible(context: FactoryContext): Promise { + if (!this.def.isCompatible) return true; + return await this.def.isCompatible(context); + } + + public getHref(context: FactoryContext): string | undefined { + if (!this.def.getHref) return undefined; + return this.def.getHref(context); + } + + public create( + serializedAction: Omit, 'factoryId'> + ): ActionDefinition { + return this.def.create(serializedAction); + } +} diff --git a/src/plugins/ui_actions/public/actions/action_factory_definition.ts b/src/plugins/ui_actions/public/actions/action_factory_definition.ts new file mode 100644 index 0000000000000..7ac94a41e7076 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_factory_definition.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ActionDefinition } from './action'; +import { Presentable, Configurable } from '../util'; +import { SerializedAction } from './types'; + +/** + * This is a convenience interface for registering new action factories. + */ +export interface ActionFactoryDefinition< + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object +> extends Partial>, Configurable { + /** + * Unique ID of the action factory. This ID is used to identify this action + * factory in the registry as well as to construct actions of this type and + * identify this action factory when presenting it to the user in UI. + */ + id: string; + + /** + * This method should return a definition of a new action, normally used to + * register it in `ui_actions` registry. + */ + create( + serializedAction: Omit, 'factoryId'> + ): ActionDefinition; +} diff --git a/src/plugins/ui_actions/public/actions/action_internal.test.ts b/src/plugins/ui_actions/public/actions/action_internal.test.ts new file mode 100644 index 0000000000000..b14346180c274 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.test.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ActionDefinition } from './action'; +import { ActionInternal } from './action_internal'; + +const defaultActionDef: ActionDefinition = { + id: 'test-action', + execute: jest.fn(), +}; + +describe('ActionInternal', () => { + test('can instantiate from action definition', () => { + const action = new ActionInternal(defaultActionDef); + expect(action.id).toBe('test-action'); + }); +}); diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts new file mode 100644 index 0000000000000..245ded991c032 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Action, ActionContext as Context, ActionDefinition } from './action'; +import { Presentable } from '../util/presentable'; +import { uiToReactComponent } from '../../../kibana_react/public'; +import { ActionType } from '../types'; + +export class ActionInternal + implements Action>, Presentable> { + constructor(public readonly definition: A) {} + + public readonly id: string = this.definition.id; + public readonly type: ActionType = this.definition.type || ''; + public readonly order: number = this.definition.order || 0; + public readonly MenuItem? = this.definition.MenuItem; + public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; + + public execute(context: Context) { + return this.definition.execute(context); + } + + public getIconType(context: Context): string | undefined { + if (!this.definition.getIconType) return undefined; + return this.definition.getIconType(context); + } + + public getDisplayName(context: Context): string { + if (!this.definition.getDisplayName) return `Action: ${this.id}`; + return this.definition.getDisplayName(context); + } + + public async isCompatible(context: Context): Promise { + if (!this.definition.isCompatible) return true; + return await this.definition.isCompatible(context); + } + + public getHref(context: Context): string | undefined { + if (!this.definition.getHref) return undefined; + return this.definition.getHref(context); + } +} diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts index 90a9415c0b497..8f1cd23715d3f 100644 --- a/src/plugins/ui_actions/public/actions/create_action.ts +++ b/src/plugins/ui_actions/public/actions/create_action.ts @@ -17,11 +17,19 @@ * under the License. */ +import { ActionContextMapping } from '../types'; import { ActionByType } from './action'; import { ActionType } from '../types'; -import { ActionDefinition } from './action_definition'; +import { ActionDefinition } from './action'; -export function createAction(action: ActionDefinition): ActionByType { +interface ActionDefinitionByType + extends Omit, 'id'> { + id?: string; +} + +export function createAction( + action: ActionDefinitionByType +): ActionByType { return { getIconType: () => undefined, order: 0, @@ -30,5 +38,5 @@ export function createAction(action: ActionDefinition): getDisplayName: () => '', getHref: () => undefined, ...action, - }; + } as ActionByType; } diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts new file mode 100644 index 0000000000000..2574a9e529ebf --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts @@ -0,0 +1,646 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DynamicActionManager } from './dynamic_action_manager'; +import { ActionStorage, MemoryActionStorage, SerializedEvent } from './dynamic_action_storage'; +import { UiActionsService } from '../service'; +import { ActionFactoryDefinition } from './action_factory_definition'; +import { ActionRegistry } from '../types'; +import { SerializedAction } from './types'; +import { of } from '../../../kibana_utils'; + +const actionFactoryDefinition1: ActionFactoryDefinition = { + id: 'ACTION_FACTORY_1', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: (() => true) as any, + create: ({ name }) => ({ + id: '', + execute: async () => {}, + getDisplayName: () => name, + }), +}; + +const actionFactoryDefinition2: ActionFactoryDefinition = { + id: 'ACTION_FACTORY_2', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: (() => true) as any, + create: ({ name }) => ({ + id: '', + execute: async () => {}, + getDisplayName: () => name, + }), +}; + +const event1: SerializedEvent = { + eventId: 'EVENT_ID_1', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'Action 1', + config: {}, + }, +}; + +const event2: SerializedEvent = { + eventId: 'EVENT_ID_2', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'Action 2', + config: {}, + }, +}; + +const event3: SerializedEvent = { + eventId: 'EVENT_ID_3', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition2.id, + name: 'Action 3', + config: {}, + }, +}; + +const setup = (events: readonly SerializedEvent[] = []) => { + const isCompatible = async () => true; + const storage: ActionStorage = new MemoryActionStorage(events); + const actions: ActionRegistry = new Map(); + const uiActions = new UiActionsService({ + actions, + }); + const manager = new DynamicActionManager({ + isCompatible, + storage, + uiActions, + }); + + uiActions.registerTrigger({ + id: 'VALUE_CLICK_TRIGGER', + }); + + return { + isCompatible, + actions, + storage, + uiActions, + manager, + }; +}; + +describe('DynamicActionManager', () => { + test('can instantiate', () => { + const { manager } = setup([event1]); + expect(manager).toBeInstanceOf(DynamicActionManager); + }); + + describe('.start()', () => { + test('instantiates stored events', async () => { + const { manager, actions, uiActions } = setup([event1]); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + + await manager.start(); + + expect(create1).toHaveBeenCalledTimes(1); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(1); + }); + + test('does nothing when no events stored', async () => { + const { manager, actions, uiActions } = setup(); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + + await manager.start(); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + }); + + test('UI state is empty before manager starts', async () => { + const { manager } = setup([event1]); + + expect(manager.state.get()).toMatchObject({ + events: [], + isFetchingEvents: false, + fetchCount: 0, + }); + }); + + test('loads events into UI state', async () => { + const { manager, uiActions } = setup([event1, event2, event3]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + uiActions.registerActionFactory(actionFactoryDefinition2); + + await manager.start(); + + expect(manager.state.get()).toMatchObject({ + events: [event1, event2, event3], + isFetchingEvents: false, + fetchCount: 1, + }); + }); + + test('sets isFetchingEvents to true while fetching events', async () => { + const { manager, uiActions } = setup([event1, event2, event3]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + uiActions.registerActionFactory(actionFactoryDefinition2); + + const promise = manager.start().catch(() => {}); + + expect(manager.state.get().isFetchingEvents).toBe(true); + + await promise; + + expect(manager.state.get().isFetchingEvents).toBe(false); + }); + + test('throws if storage threw', async () => { + const { manager, storage } = setup([event1]); + + storage.list = async () => { + throw new Error('baz'); + }; + + const [, error] = await of(manager.start()); + + expect(error).toEqual(new Error('baz')); + }); + + test('sets UI state error if error happened during initial fetch', async () => { + const { manager, storage } = setup([event1]); + + storage.list = async () => { + throw new Error('baz'); + }; + + await of(manager.start()); + + expect(manager.state.get().fetchError!.message).toBe('baz'); + }); + }); + + describe('.stop()', () => { + test('removes events from UI actions registry', async () => { + const { manager, actions, uiActions } = setup([event1, event2]); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(actions.size).toBe(0); + + await manager.start(); + + expect(actions.size).toBe(2); + + await manager.stop(); + + expect(actions.size).toBe(0); + }); + }); + + describe('.createEvent()', () => { + describe('when storage succeeds', () => { + test('stores new event in storage', async () => { + const { manager, storage, uiActions } = setup([]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + expect(await storage.count()).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(await storage.count()).toBe(1); + + const [event] = await storage.list(); + + expect(event).toMatchObject({ + eventId: expect.any(String), + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }, + }); + }); + + test('adds event to UI state', async () => { + const { manager, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(manager.state.get().events.length).toBe(1); + }); + + test('optimistically adds event to UI state', async () => { + const { manager, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); + + expect(manager.state.get().events.length).toBe(1); + + await promise; + + expect(manager.state.get().events.length).toBe(1); + }); + + test('instantiates event in actions service', async () => { + const { manager, uiActions, actions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(actions.size).toBe(1); + }); + }); + + describe('when storage fails', () => { + test('throws an error', async () => { + const { manager, storage, uiActions } = setup([]); + + storage.create = async () => { + throw new Error('foo'); + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + const [, error] = await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(error).toEqual(new Error('foo')); + }); + + test('does not add even to UI state', async () => { + const { manager, storage, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(manager.state.get().events.length).toBe(0); + }); + + test('optimistically adds event to UI state and then removes it', async () => { + const { manager, storage, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); + + expect(manager.state.get().events.length).toBe(1); + + await promise; + + expect(manager.state.get().events.length).toBe(0); + }); + + test('does not instantiate event in actions service', async () => { + const { manager, storage, uiActions, actions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(0); + + await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(actions.size).toBe(0); + }); + }); + }); + + describe('.updateEvent()', () => { + describe('when storage succeeds', () => { + test('un-registers old event from ui actions service and registers the new one', async () => { + const { manager, actions, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + expect(actions.size).toBe(1); + + const registeredAction1 = actions.values().next().value; + + expect(registeredAction1.getDisplayName()).toBe('Action 3'); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(actions.size).toBe(1); + + const registeredAction2 = actions.values().next().value; + + expect(registeredAction2.getDisplayName()).toBe('foo'); + }); + + test('updates event in storage', async () => { + const { manager, storage, uiActions } = setup([event3]); + const storageUpdateSpy = jest.spyOn(storage, 'update'); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(storageUpdateSpy).toHaveBeenCalledTimes(0); + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(storageUpdateSpy).toHaveBeenCalledTimes(1); + expect(storageUpdateSpy.mock.calls[0][0]).toMatchObject({ + eventId: expect.any(String), + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition2.id, + }, + }); + }); + + test('updates event in UI state', async () => { + const { manager, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(manager.state.get().events[0].action.name).toBe('foo'); + }); + + test('optimistically updates event in UI state', async () => { + const { manager, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + const promise = manager + .updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) + .catch(e => e); + + expect(manager.state.get().events[0].action.name).toBe('foo'); + + await promise; + }); + }); + + describe('when storage fails', () => { + test('throws error', async () => { + const { manager, storage, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + const [, error] = await of( + manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) + ); + + expect(error).toEqual(new Error('bar')); + }); + + test('keeps the old action in actions registry', async () => { + const { manager, storage, actions, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + expect(actions.size).toBe(1); + + const registeredAction1 = actions.values().next().value; + + expect(registeredAction1.getDisplayName()).toBe('Action 3'); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); + + expect(actions.size).toBe(1); + + const registeredAction2 = actions.values().next().value; + + expect(registeredAction2.getDisplayName()).toBe('Action 3'); + }); + + test('keeps old event in UI state', async () => { + const { manager, storage, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + }); + }); + }); + + describe('.deleteEvents()', () => { + describe('when storage succeeds', () => { + test('removes all actions from uiActions service', async () => { + const { manager, actions, uiActions } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(2); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(actions.size).toBe(0); + }); + + test('removes all events from storage', async () => { + const { manager, uiActions, storage } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(await storage.list()).toEqual([event2, event1]); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(await storage.list()).toEqual([]); + }); + + test('removes all events from UI state', async () => { + const { manager, uiActions } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events).toEqual([event2, event1]); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(manager.state.get().events).toEqual([]); + }); + }); + }); +}); diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts new file mode 100644 index 0000000000000..97eb5b05fbbc2 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts @@ -0,0 +1,284 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { Subscription } from 'rxjs'; +import { ActionStorage, SerializedEvent } from './dynamic_action_storage'; +import { UiActionsService } from '../service'; +import { SerializedAction } from './types'; +import { TriggerContextMapping } from '../types'; +import { ActionDefinition } from './action'; +import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state'; +import { StateContainer, createStateContainer } from '../../../kibana_utils'; + +const compareEvents = ( + a: ReadonlyArray<{ eventId: string }>, + b: ReadonlyArray<{ eventId: string }> +) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i].eventId !== b[i].eventId) return false; + return true; +}; + +export type DynamicActionManagerState = State; + +export interface DynamicActionManagerParams { + storage: ActionStorage; + uiActions: Pick< + UiActionsService, + 'registerAction' | 'attachAction' | 'unregisterAction' | 'detachAction' | 'getActionFactory' + >; + isCompatible: (context: C) => Promise; +} + +export class DynamicActionManager { + static idPrefixCounter = 0; + + private readonly idPrefix = `D_ACTION_${DynamicActionManager.idPrefixCounter++}_`; + private stopped: boolean = false; + private reloadSubscription?: Subscription; + + /** + * UI State of the dynamic action manager. + */ + protected readonly ui = createStateContainer(defaultState, transitions, selectors); + + constructor(protected readonly params: DynamicActionManagerParams) {} + + protected getEvent(eventId: string): SerializedEvent { + const oldEvent = this.ui.selectors.getEvent(eventId); + if (!oldEvent) throw new Error(`Could not find event [eventId = ${eventId}].`); + return oldEvent; + } + + /** + * We prefix action IDs with a unique `.idPrefix`, so we can render the + * same dashboard twice on the screen. + */ + protected generateActionId(eventId: string): string { + return this.idPrefix + eventId; + } + + protected reviveAction(event: SerializedEvent) { + const { eventId, triggers, action } = event; + const { uiActions, isCompatible } = this.params; + + const actionId = this.generateActionId(eventId); + const factory = uiActions.getActionFactory(event.action.factoryId); + const actionDefinition: ActionDefinition = { + ...factory.create(action as SerializedAction), + id: actionId, + isCompatible, + }; + + uiActions.registerAction(actionDefinition); + for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId); + } + + protected killAction({ eventId, triggers }: SerializedEvent) { + const { uiActions } = this.params; + const actionId = this.generateActionId(eventId); + + for (const trigger of triggers) uiActions.detachAction(trigger as any, actionId); + uiActions.unregisterAction(actionId); + } + + private syncId = 0; + + /** + * This function is called every time stored events might have changed not by + * us. For example, when in edit mode on dashboard user presses "back" button + * in the browser, then contents of storage changes. + */ + private onSync = () => { + if (this.stopped) return; + + (async () => { + const syncId = ++this.syncId; + const events = await this.params.storage.list(); + + if (this.stopped) return; + if (syncId !== this.syncId) return; + if (compareEvents(events, this.ui.get().events)) return; + + for (const event of this.ui.get().events) this.killAction(event); + for (const event of events) this.reviveAction(event); + this.ui.transitions.finishFetching(events); + })().catch(error => { + /* eslint-disable */ + console.log('Dynamic action manager storage reload failed.'); + console.error(error); + /* eslint-enable */ + }); + }; + + // Public API: --------------------------------------------------------------- + + /** + * Read-only state container of dynamic action manager. Use it to perform all + * *read* operations. + */ + public readonly state: StateContainer = this.ui; + + /** + * 1. Loads all events from @type {DynamicActionStorage} storage. + * 2. Creates actions for each event in `ui_actions` registry. + * 3. Adds events to UI state. + * 4. Does nothing if dynamic action manager was stopped or if event fetching + * is already taking place. + */ + public async start() { + if (this.stopped) return; + if (this.ui.get().isFetchingEvents) return; + + this.ui.transitions.startFetching(); + try { + const events = await this.params.storage.list(); + for (const event of events) this.reviveAction(event); + this.ui.transitions.finishFetching(events); + } catch (error) { + this.ui.transitions.failFetching(error instanceof Error ? error : { message: String(error) }); + throw error; + } + + if (this.params.storage.reload$) { + this.reloadSubscription = this.params.storage.reload$.subscribe(this.onSync); + } + } + + /** + * 1. Removes all events from `ui_actions` registry. + * 2. Puts dynamic action manager is stopped state. + */ + public async stop() { + this.stopped = true; + const events = await this.params.storage.list(); + + for (const event of events) { + this.killAction(event); + } + + if (this.reloadSubscription) { + this.reloadSubscription.unsubscribe(); + } + } + + /** + * Creates a new event. + * + * 1. Stores event in @type {DynamicActionStorage} storage. + * 2. Optimistically adds it to UI state, and rolls back on failure. + * 3. Adds action to `ui_actions` registry. + * + * @param action Dynamic action for which to create an event. + * @param triggers List of triggers to which action should react. + */ + public async createEvent( + action: SerializedAction, + triggers: Array + ) { + const event: SerializedEvent = { + eventId: uuidv4(), + triggers, + action, + }; + + this.ui.transitions.addEvent(event); + try { + await this.params.storage.create(event); + this.reviveAction(event); + } catch (error) { + this.ui.transitions.removeEvent(event.eventId); + throw error; + } + } + + /** + * Updates an existing event. Fails if event with given `eventId` does not + * exit. + * + * 1. Updates the event in @type {DynamicActionStorage} storage. + * 2. Optimistically replaces the old event by the new one in UI state, and + * rolls back on failure. + * 3. Replaces action in `ui_actions` registry with the new event. + * + * + * @param eventId ID of the event to replace. + * @param action New action for which to create the event. + * @param triggers List of triggers to which action should react. + */ + public async updateEvent( + eventId: string, + action: SerializedAction, + triggers: Array + ) { + const event: SerializedEvent = { + eventId, + triggers, + action, + }; + const oldEvent = this.getEvent(eventId); + this.killAction(oldEvent); + + this.reviveAction(event); + this.ui.transitions.replaceEvent(event); + + try { + await this.params.storage.update(event); + } catch (error) { + this.killAction(event); + this.reviveAction(oldEvent); + this.ui.transitions.replaceEvent(oldEvent); + throw error; + } + } + + /** + * Removes existing event. Throws if event does not exist. + * + * 1. Removes the event from @type {DynamicActionStorage} storage. + * 2. Optimistically removes event from UI state, and puts it back on failure. + * 3. Removes associated action from `ui_actions` registry. + * + * @param eventId ID of the event to remove. + */ + public async deleteEvent(eventId: string) { + const event = this.getEvent(eventId); + + this.killAction(event); + this.ui.transitions.removeEvent(eventId); + + try { + await this.params.storage.remove(eventId); + } catch (error) { + this.reviveAction(event); + this.ui.transitions.addEvent(event); + throw error; + } + } + + /** + * Deletes multiple events at once. + * + * @param eventIds List of event IDs. + */ + public async deleteEvents(eventIds: string[]) { + await Promise.all(eventIds.map(this.deleteEvent.bind(this))); + } +} diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts new file mode 100644 index 0000000000000..636af076ea39f --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts @@ -0,0 +1,111 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SerializedEvent } from './dynamic_action_storage'; + +/** + * This interface represents the state of @type {DynamicActionManager} at any + * point in time. + */ +export interface State { + /** + * Whether dynamic action manager is currently in process of fetching events + * from storage. + */ + readonly isFetchingEvents: boolean; + + /** + * Number of times event fetching has been completed. + */ + readonly fetchCount: number; + + /** + * Error received last time when fetching events. + */ + readonly fetchError?: { + message: string; + }; + + /** + * List of all fetched events. + */ + readonly events: readonly SerializedEvent[]; +} + +export interface Transitions { + startFetching: (state: State) => () => State; + finishFetching: (state: State) => (events: SerializedEvent[]) => State; + failFetching: (state: State) => (error: { message: string }) => State; + addEvent: (state: State) => (event: SerializedEvent) => State; + removeEvent: (state: State) => (eventId: string) => State; + replaceEvent: (state: State) => (event: SerializedEvent) => State; +} + +export interface Selectors { + getEvent: (state: State) => (eventId: string) => SerializedEvent | null; +} + +export const defaultState: State = { + isFetchingEvents: false, + fetchCount: 0, + events: [], +}; + +export const transitions: Transitions = { + startFetching: state => () => ({ ...state, isFetchingEvents: true }), + + finishFetching: state => events => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: undefined, + events, + }), + + failFetching: state => ({ message }) => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: { message }, + }), + + addEvent: state => (event: SerializedEvent) => ({ + ...state, + events: [...state.events, event], + }), + + removeEvent: state => (eventId: string) => ({ + ...state, + events: state.events ? state.events.filter(event => event.eventId !== eventId) : state.events, + }), + + replaceEvent: state => event => { + const index = state.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index === -1) return state; + + return { + ...state, + events: [...state.events.slice(0, index), event, ...state.events.slice(index + 1)], + }; + }, +}; + +export const selectors: Selectors = { + getEvent: state => eventId => state.events.find(event => event.eventId === eventId) || null, +}; diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts b/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts new file mode 100644 index 0000000000000..28550a671782e --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts @@ -0,0 +1,102 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* eslint-disable max-classes-per-file */ + +import { Observable, Subject } from 'rxjs'; +import { SerializedAction } from './types'; + +/** + * Serialized representation of event-action pair, used to persist in storage. + */ +export interface SerializedEvent { + eventId: string; + triggers: string[]; + action: SerializedAction; +} + +/** + * This CRUD interface needs to be implemented by dynamic action users if they + * want to persist the dynamic actions. It has a default implementation in + * Embeddables, however one can use the dynamic actions without Embeddables, + * in that case they have to implement this interface. + */ +export interface ActionStorage { + create(event: SerializedEvent): Promise; + update(event: SerializedEvent): Promise; + remove(eventId: string): Promise; + read(eventId: string): Promise; + count(): Promise; + list(): Promise; + + /** + * Triggered every time events changed in storage and should be re-loaded. + */ + readonly reload$?: Observable; +} + +export abstract class AbstractActionStorage implements ActionStorage { + public readonly reload$: Observable & Pick, 'next'> = new Subject(); + + public async count(): Promise { + return (await this.list()).length; + } + + public async read(eventId: string): Promise { + const events = await this.list(); + const event = events.find(ev => ev.eventId === eventId); + if (!event) throw new Error(`Event [eventId = ${eventId}] not found.`); + return event; + } + + abstract create(event: SerializedEvent): Promise; + abstract update(event: SerializedEvent): Promise; + abstract remove(eventId: string): Promise; + abstract list(): Promise; +} + +/** + * This is an in-memory implementation of ActionStorage. It is used in testing, + * but can also be used production code to store events in memory. + */ +export class MemoryActionStorage extends AbstractActionStorage { + constructor(public events: readonly SerializedEvent[] = []) { + super(); + } + + public async list() { + return this.events.map(event => ({ ...event })); + } + + public async create(event: SerializedEvent) { + this.events = [...this.events, { ...event }]; + } + + public async update(event: SerializedEvent) { + const index = this.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index < 0) throw new Error(`Event [eventId = ${event.eventId}] not found`); + this.events = [...this.events.slice(0, index), { ...event }, ...this.events.slice(index + 1)]; + } + + public async remove(eventId: string) { + const index = this.events.findIndex(ev => eventId === ev.eventId); + if (index < 0) throw new Error(`Event [eventId = ${eventId}] not found`); + this.events = [...this.events.slice(0, index), ...this.events.slice(index + 1)]; + } +} diff --git a/src/plugins/ui_actions/public/actions/index.ts b/src/plugins/ui_actions/public/actions/index.ts index 64bfd368e3dfa..0ddba197aced6 100644 --- a/src/plugins/ui_actions/public/actions/index.ts +++ b/src/plugins/ui_actions/public/actions/index.ts @@ -18,5 +18,11 @@ */ export * from './action'; +export * from './action_internal'; +export * from './action_factory_definition'; +export * from './action_factory'; export * from './create_action'; export * from './incompatible_action_error'; +export * from './dynamic_action_storage'; +export * from './dynamic_action_manager'; +export * from './types'; diff --git a/src/plugins/ui_actions/public/actions/types.ts b/src/plugins/ui_actions/public/actions/types.ts new file mode 100644 index 0000000000000..465f091e45ef1 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/types.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface SerializedAction { + readonly factoryId: string; + readonly name: string; + readonly config: Config; +} diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 3dce2c1f4c257..ec58261d9e4f7 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -24,19 +24,25 @@ import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; +export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { + defaultMessage: 'Options', +}); + /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -export async function buildContextMenuForActions({ +export async function buildContextMenuForActions({ actions, actionContext, + title = defaultTitle, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + actionContext: Context; + title?: string; closeMenu: () => void; }): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ + const menuItems = await buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, @@ -44,9 +50,7 @@ export async function buildContextMenuForActions({ return { id: 'mainMenu', - title: i18n.translate('uiActions.actionPanel.title', { - defaultMessage: 'Options', - }), + title, items: menuItems, }; } @@ -54,49 +58,41 @@ export async function buildContextMenuForActions({ /** * Transform an array of Actions into the shape needed to build an EUIContextMenu */ -async function buildEuiContextMenuPanelItems({ +async function buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + actionContext: Context; closeMenu: () => void; }) { - const items: EuiContextMenuPanelItemDescriptor[] = []; - const promises = actions.map(async action => { + const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); + const promises = actions.map(async (action, index) => { const isCompatible = await action.isCompatible(actionContext); if (!isCompatible) { return; } - items.push( - convertPanelActionToContextMenuItem({ - action, - actionContext, - closeMenu, - }) - ); + items[index] = convertPanelActionToContextMenuItem({ + action, + actionContext, + closeMenu, + }); }); await Promise.all(promises); - return items; + return items.filter(Boolean); } -/** - * - * @param {ContextMenuAction} action - * @param {Embeddable} embeddable - * @return {EuiContextMenuPanelItemDescriptor} - */ -function convertPanelActionToContextMenuItem({ +function convertPanelActionToContextMenuItem({ action, actionContext, closeMenu, }: { - action: Action; - actionContext: A; + action: Action; + actionContext: Context; closeMenu: () => void; }): EuiContextMenuPanelItemDescriptor { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { @@ -115,8 +111,11 @@ function convertPanelActionToContextMenuItem({ closeMenu(); }; - if (action.getHref && action.getHref(actionContext)) { - menuPanelItem.href = action.getHref(actionContext); + if (action.getHref) { + const href = action.getHref(actionContext); + if (href) { + menuPanelItem.href = action.getHref(actionContext); + } } return menuPanelItem; diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 49b6bd5e17699..9265d35bad9a9 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -26,8 +26,26 @@ export function plugin(initializerContext: PluginInitializerContext) { export { UiActionsSetup, UiActionsStart } from './plugin'; export { UiActionsServiceParams, UiActionsService } from './service'; -export { Action, createAction, IncompatibleActionError } from './actions'; +export { + Action, + ActionDefinition as UiActionsActionDefinition, + ActionFactoryDefinition as UiActionsActionFactoryDefinition, + ActionInternal as UiActionsActionInternal, + ActionStorage as UiActionsActionStorage, + AbstractActionStorage as UiActionsAbstractActionStorage, + createAction, + DynamicActionManager, + DynamicActionManagerState, + IncompatibleActionError, + SerializedAction as UiActionsSerializedAction, + SerializedEvent as UiActionsSerializedEvent, +} from './actions'; export { buildContextMenuForActions } from './context_menu'; +export { + Presentable as UiActionsPresentable, + Configurable as UiActionsConfigurable, + CollectConfigProps as UiActionsCollectConfigProps, +} from './util'; export { Trigger, TriggerContext, @@ -39,4 +57,4 @@ export { applyFilterTrigger, } from './triggers'; export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; -export { ActionByType } from './actions'; +export { ActionByType, DynamicActionManager as UiActionsDynamicActionManager } from './actions'; diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index c1be6b2626525..4de38eb5421e9 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -28,10 +28,13 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { + addTriggerAction: jest.fn(), attachAction: jest.fn(), detachAction: jest.fn(), registerAction: jest.fn(), + registerActionFactory: jest.fn(), registerTrigger: jest.fn(), + unregisterAction: jest.fn(), }; return setupContract; }; @@ -39,16 +42,21 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { const startContract: Start = { attachAction: jest.fn(), - registerAction: jest.fn(), - registerTrigger: jest.fn(), - getAction: jest.fn(), + unregisterAction: jest.fn(), + addTriggerAction: jest.fn(), + clear: jest.fn(), detachAction: jest.fn(), executeTriggerActions: jest.fn(), + fork: jest.fn(), + getAction: jest.fn(), + getActionFactories: jest.fn(), + getActionFactory: jest.fn(), getTrigger: jest.fn(), getTriggerActions: jest.fn((id: TriggerId) => []), getTriggerCompatibleActions: jest.fn(), - clear: jest.fn(), - fork: jest.fn(), + registerAction: jest.fn(), + registerActionFactory: jest.fn(), + registerTrigger: jest.fn(), }; return startContract; diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 928e57937a9b5..88a5cb04eac6f 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -23,7 +23,13 @@ import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './tri export type UiActionsSetup = Pick< UiActionsService, - 'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger' + | 'addTriggerAction' + | 'attachAction' + | 'detachAction' + | 'registerAction' + | 'registerActionFactory' + | 'registerTrigger' + | 'unregisterAction' >; export type UiActionsStart = PublicMethodsOf; diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts index bdf71a25e6dbc..41e2b57d53dd8 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -18,7 +18,13 @@ */ import { UiActionsService } from './ui_actions_service'; -import { Action, createAction } from '../actions'; +import { + Action, + ActionInternal, + createAction, + ActionFactoryDefinition, + ActionFactory, +} from '../actions'; import { createHelloWorldAction } from '../tests/test_samples'; import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types'; import { Trigger } from '../triggers'; @@ -102,6 +108,21 @@ describe('UiActionsService', () => { type: 'test' as ActionType, }); }); + + test('return action instance', () => { + const service = new UiActionsService(); + const action = service.registerAction({ + id: 'test', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + type: 'test' as ActionType, + }); + + expect(action).toBeInstanceOf(ActionInternal); + expect(action.id).toBe('test'); + }); }); describe('.getTriggerActions()', () => { @@ -139,13 +160,14 @@ describe('UiActionsService', () => { expect(list0).toHaveLength(0); - service.attachAction(FOO_TRIGGER, action1); + service.addTriggerAction(FOO_TRIGGER, action1); const list1 = service.getTriggerActions(FOO_TRIGGER); expect(list1).toHaveLength(1); - expect(list1).toEqual([action1]); + expect(list1[0]).toBeInstanceOf(ActionInternal); + expect(list1[0].id).toBe(action1.id); - service.attachAction(FOO_TRIGGER, action2); + service.addTriggerAction(FOO_TRIGGER, action2); const list2 = service.getTriggerActions(FOO_TRIGGER); expect(list2).toHaveLength(2); @@ -164,7 +186,7 @@ describe('UiActionsService', () => { service.registerAction(helloWorldAction); expect(actions.size - length).toBe(1); - expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction); + expect(actions.get(helloWorldAction.id)!.id).toBe(helloWorldAction.id); }); test('getTriggerCompatibleActions returns attached actions', async () => { @@ -178,7 +200,7 @@ describe('UiActionsService', () => { title: 'My trigger', }; service.registerTrigger(testTrigger); - service.attachAction(MY_TRIGGER, helloWorldAction); + service.addTriggerAction(MY_TRIGGER, helloWorldAction); const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, { hi: 'there', @@ -204,7 +226,7 @@ describe('UiActionsService', () => { }; service.registerTrigger(testTrigger); - service.attachAction(testTrigger.id, action); + service.addTriggerAction(testTrigger.id, action); const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { accept: true, @@ -288,7 +310,7 @@ describe('UiActionsService', () => { id: FOO_TRIGGER, }); service1.registerAction(testAction1); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); @@ -309,14 +331,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service2.attachAction(FOO_TRIGGER, testAction2); + service2.addTriggerAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); @@ -330,14 +352,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service1.attachAction(FOO_TRIGGER, testAction2); + service1.addTriggerAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); @@ -392,7 +414,7 @@ describe('UiActionsService', () => { } as any; service.registerTrigger(trigger); - service.attachAction(MY_TRIGGER, action); + service.addTriggerAction(MY_TRIGGER, action); const actions = service.getTriggerActions(trigger.id); @@ -400,7 +422,7 @@ describe('UiActionsService', () => { expect(actions[0].id).toBe(ACTION_HELLO_WORLD); }); - test('can detach an action to a trigger', () => { + test('can detach an action from a trigger', () => { const service = new UiActionsService(); const trigger: Trigger = { @@ -413,7 +435,7 @@ describe('UiActionsService', () => { service.registerTrigger(trigger); service.registerAction(action); - service.attachAction(trigger.id, action); + service.addTriggerAction(trigger.id, action); service.detachAction(trigger.id, action.id); const actions2 = service.getTriggerActions(trigger.id); @@ -445,7 +467,7 @@ describe('UiActionsService', () => { } as any; service.registerAction(action); - expect(() => service.attachAction('i do not exist' as TriggerId, action)).toThrowError( + expect(() => service.addTriggerAction('i do not exist' as TriggerId, action)).toThrowError( 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = ACTION_HELLO_WORLD].' ); }); @@ -475,4 +497,64 @@ describe('UiActionsService', () => { ); }); }); + + describe('action factories', () => { + const factoryDefinition1: ActionFactoryDefinition = { + id: 'test-factory-1', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + const factoryDefinition2: ActionFactoryDefinition = { + id: 'test-factory-2', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + + test('.getActionFactories() returns empty array if no action factories registered', () => { + const service = new UiActionsService(); + + const factories = service.getActionFactories(); + + expect(factories).toEqual([]); + }); + + test('can register and retrieve an action factory', () => { + const service = new UiActionsService(); + + service.registerActionFactory(factoryDefinition1); + + const factory = service.getActionFactory(factoryDefinition1.id); + + expect(factory).toBeInstanceOf(ActionFactory); + expect(factory.id).toBe(factoryDefinition1.id); + }); + + test('can retrieve all action factories', () => { + const service = new UiActionsService(); + + service.registerActionFactory(factoryDefinition1); + service.registerActionFactory(factoryDefinition2); + + const factories = service.getActionFactories(); + const factoriesSorted = [...factories].sort((f1, f2) => (f1.id > f2.id ? 1 : -1)); + + expect(factoriesSorted.length).toBe(2); + expect(factoriesSorted[0].id).toBe(factoryDefinition1.id); + expect(factoriesSorted[1].id).toBe(factoryDefinition2.id); + }); + + test('throws when retrieving action factory that does not exist', () => { + const service = new UiActionsService(); + + service.registerActionFactory(factoryDefinition1); + + expect(() => service.getActionFactory('UNKNOWN_ID')).toThrowError( + 'Action factory [actionFactoryId = UNKNOWN_ID] does not exist.' + ); + }); + }); }); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index f7718e63773f5..8bd3bb34fbbd8 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -24,8 +24,17 @@ import { TriggerId, TriggerContextMapping, ActionType, + ActionFactoryRegistry, } from '../types'; -import { Action, ActionByType } from '../actions'; +import { + ActionInternal, + Action, + ActionByType, + ActionFactory, + ActionDefinition, + ActionFactoryDefinition, + ActionContext, +} from '../actions'; import { Trigger, TriggerContext } from '../triggers/trigger'; import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerContract } from '../triggers/trigger_contract'; @@ -38,21 +47,25 @@ export interface UiActionsServiceParams { * A 1-to-N mapping from `Trigger` to zero or more `Action`. */ readonly triggerToActions?: TriggerToActionsRegistry; + readonly actionFactories?: ActionFactoryRegistry; } export class UiActionsService { protected readonly triggers: TriggerRegistry; protected readonly actions: ActionRegistry; protected readonly triggerToActions: TriggerToActionsRegistry; + protected readonly actionFactories: ActionFactoryRegistry; constructor({ triggers = new Map(), actions = new Map(), triggerToActions = new Map(), + actionFactories = new Map(), }: UiActionsServiceParams = {}) { this.triggers = triggers; this.actions = actions; this.triggerToActions = triggerToActions; + this.actionFactories = actionFactories; } public readonly registerTrigger = (trigger: Trigger) => { @@ -76,49 +89,44 @@ export class UiActionsService { return trigger.contract; }; - public readonly registerAction = (action: ActionByType) => { - if (this.actions.has(action.id)) { - throw new Error(`Action [action.id = ${action.id}] already registered.`); + public readonly registerAction = ( + definition: A + ): ActionInternal => { + if (this.actions.has(definition.id)) { + throw new Error(`Action [action.id = ${definition.id}] already registered.`); } + const action = new ActionInternal(definition); + this.actions.set(action.id, action); + + return action; }; - public readonly getAction = (id: string): ActionByType => { - if (!this.actions.has(id)) { - throw new Error(`Action [action.id = ${id}] not registered.`); + public readonly unregisterAction = (actionId: string): void => { + if (!this.actions.has(actionId)) { + throw new Error(`Action [action.id = ${actionId}] is not registered.`); } - return this.actions.get(id) as ActionByType; + this.actions.delete(actionId); }; - public readonly attachAction = ( - triggerId: TType, - // The action can accept partial or no context, but if it needs context not provided - // by this type of trigger, typescript will complain. yay! - action: ActionByType & Action + public readonly attachAction = ( + triggerId: TriggerId, + actionId: string ): void => { - if (!this.actions.has(action.id)) { - this.registerAction(action); - } else { - const registeredAction = this.actions.get(action.id); - if (registeredAction !== action) { - throw new Error(`A different action instance with this id is already registered.`); - } - } - const trigger = this.triggers.get(triggerId); if (!trigger) { throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].` + `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` ); } const actionIds = this.triggerToActions.get(triggerId); - if (!actionIds!.find(id => id === action.id)) { - this.triggerToActions.set(triggerId, [...actionIds!, action.id]); + if (!actionIds!.find(id => id === actionId)) { + this.triggerToActions.set(triggerId, [...actionIds!, actionId]); } }; @@ -139,6 +147,26 @@ export class UiActionsService { ); }; + public readonly addTriggerAction = ( + triggerId: TType, + // The action can accept partial or no context, but if it needs context not provided + // by this type of trigger, typescript will complain. yay! + action: ActionByType & Action + ): void => { + if (!this.actions.has(action.id)) this.registerAction(action); + this.attachAction(triggerId, action.id); + }; + + public readonly getAction = ( + id: string + ): Action> => { + if (!this.actions.has(id)) { + throw new Error(`Action [action.id = ${id}] not registered.`); + } + + return this.actions.get(id) as ActionInternal; + }; + public readonly getTriggerActions = ( triggerId: T ): Array> => { @@ -147,9 +175,9 @@ export class UiActionsService { const actionIds = this.triggerToActions.get(triggerId); - const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array< - Action - >; + const actions = actionIds! + .map(actionId => this.actions.get(actionId) as ActionInternal) + .filter(Boolean); return actions as Array>>; }; @@ -187,6 +215,7 @@ export class UiActionsService { this.actions.clear(); this.triggers.clear(); this.triggerToActions.clear(); + this.actionFactories.clear(); }; /** @@ -206,4 +235,41 @@ export class UiActionsService { return new UiActionsService({ triggers, actions, triggerToActions }); }; + + /** + * Register an action factory. Action factories are used to configure and + * serialize/deserialize dynamic actions. + */ + public readonly registerActionFactory = < + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object + >( + definition: ActionFactoryDefinition + ) => { + if (this.actionFactories.has(definition.id)) { + throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`); + } + + const actionFactory = new ActionFactory(definition); + + this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); + }; + + public readonly getActionFactory = (actionFactoryId: string): ActionFactory => { + const actionFactory = this.actionFactories.get(actionFactoryId); + + if (!actionFactory) { + throw new Error(`Action factory [actionFactoryId = ${actionFactoryId}] does not exist.`); + } + + return actionFactory; + }; + + /** + * Returns an array of all action factories. + */ + public readonly getActionFactories = (): ActionFactory[] => { + return [...this.actionFactories.values()]; + }; } diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 5b427f918c173..ade21ee4b7d91 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -69,7 +69,7 @@ test('executes a single action mapped to a trigger', async () => { const action = createTestAction('test1', () => true); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const context = {}; const start = doStart(); @@ -109,7 +109,7 @@ test('does not execute an incompatible action', async () => { ); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const start = doStart(); const context = { @@ -130,8 +130,8 @@ test('shows a context menu when more than one action is mapped to a trigger', as const action2 = createTestAction('test2', () => true); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action1); - setup.attachAction(trigger.id, action2); + setup.addTriggerAction(trigger.id, action1); + setup.addTriggerAction(trigger.id, action2); expect(openContextMenu).toHaveBeenCalledTimes(0); @@ -155,7 +155,7 @@ test('passes whole action context to isCompatible()', async () => { }); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const start = doStart(); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index f5a6a96fb41a4..55ccac42ff255 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Action } from '../actions'; +import { ActionInternal, Action } from '../actions'; import { uiActionsPluginMock } from '../mocks'; import { TriggerId, ActionType } from '../types'; @@ -47,13 +47,14 @@ test('returns actions set on trigger', () => { expect(list0).toHaveLength(0); - setup.attachAction('trigger' as TriggerId, action1); + setup.addTriggerAction('trigger' as TriggerId, action1); const list1 = start.getTriggerActions('trigger' as TriggerId); expect(list1).toHaveLength(1); - expect(list1).toEqual([action1]); + expect(list1[0]).toBeInstanceOf(ActionInternal); + expect(list1[0].id).toBe(action1.id); - setup.attachAction('trigger' as TriggerId, action2); + setup.addTriggerAction('trigger' as TriggerId, action2); const list2 = start.getTriggerActions('trigger' as TriggerId); expect(list2).toHaveLength(2); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index c5e68e5d5ca5a..21dd17ed82e3f 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { id: 'trigger' as TriggerId, title: 'trigger', }); - uiActions.setup.attachAction('trigger' as TriggerId, action); + uiActions.setup.addTriggerAction('trigger' as TriggerId, action); }); test('can register action', async () => { @@ -58,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => { title: 'My trigger', }; setup.registerTrigger(testTrigger); - setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction); + setup.addTriggerAction('MY-TRIGGER' as TriggerId, helloWorldAction); const start = doStart(); const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {}); @@ -84,7 +84,7 @@ test('filters out actions not applicable based on the context', async () => { setup.registerTrigger(testTrigger); setup.registerAction(action1); - setup.attachAction(testTrigger.id, action1); + setup.addTriggerAction(testTrigger.id, action1); const start = doStart(); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); diff --git a/src/plugins/ui_actions/public/tests/test_samples/index.ts b/src/plugins/ui_actions/public/tests/test_samples/index.ts index 7d63b1b6d5669..dfa71cec89595 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/index.ts +++ b/src/plugins/ui_actions/public/tests/test_samples/index.ts @@ -16,4 +16,5 @@ * specific language governing permissions and limitations * under the License. */ + export { createHelloWorldAction } from './hello_world_action'; diff --git a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index c638db0ce9dab..9758508dc3dac 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER'; export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { id: SELECT_RANGE_TRIGGER, - title: 'Select range', + title: '', description: 'Applies a range filter', }; diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index 5b670df354f78..9885ed3abe93b 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -72,6 +72,7 @@ export class TriggerInternal { const panel = await buildContextMenuForActions({ actions, actionContext: context, + title: this.trigger.title, closeMenu: () => session.close(), }); const session = openContextMenu([panel]); diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index ad32bdc1b564e..2671584d105c8 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER'; export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { id: VALUE_CLICK_TRIGGER, - title: 'Value clicked', + title: '', description: 'Value was clicked', }; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index c7e6d61e15f31..2cb4a8f26a879 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -17,15 +17,17 @@ * under the License. */ -import { ActionByType } from './actions/action'; +import { ActionInternal } from './actions/action_internal'; import { TriggerInternal } from './triggers/trigger_internal'; +import { ActionFactory } from './actions'; import { EmbeddableVisTriggerContext, IEmbeddable } from '../../embeddable/public'; import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; export type TriggerRegistry = Map>; -export type ActionRegistry = Map>; +export type ActionRegistry = Map; export type TriggerToActionsRegistry = Map; +export type ActionFactoryRegistry = Map; const DEFAULT_TRIGGER = ''; diff --git a/src/plugins/ui_actions/public/util/configurable.ts b/src/plugins/ui_actions/public/util/configurable.ts new file mode 100644 index 0000000000000..d3a527a2183b1 --- /dev/null +++ b/src/plugins/ui_actions/public/util/configurable.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UiComponent } from 'src/plugins/kibana_utils/common'; + +/** + * Represents something that can be configured by user using UI. + */ +export interface Configurable { + /** + * Create default config for this item, used when item is created for the first time. + */ + readonly createConfig: () => Config; + + /** + * Is this config valid. Used to validate user's input before saving. + */ + readonly isConfigValid: (config: Config) => boolean; + + /** + * `UiComponent` to be rendered when collecting configuration for this item. + */ + readonly CollectConfig: UiComponent>; +} + +/** + * Props provided to `CollectConfig` component on every re-render. + */ +export interface CollectConfigProps { + /** + * Current (latest) config of the item. + */ + config: Config; + + /** + * Callback called when user updates the config in UI. + */ + onConfig: (config: Config) => void; + + /** + * Context information about where component is being rendered. + */ + context: Context; +} diff --git a/src/plugins/ui_actions/public/util/index.ts b/src/plugins/ui_actions/public/util/index.ts new file mode 100644 index 0000000000000..53c6109cac4ca --- /dev/null +++ b/src/plugins/ui_actions/public/util/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './presentable'; +export * from './configurable'; diff --git a/src/plugins/ui_actions/public/actions/action_definition.ts b/src/plugins/ui_actions/public/util/presentable.ts similarity index 50% rename from src/plugins/ui_actions/public/actions/action_definition.ts rename to src/plugins/ui_actions/public/util/presentable.ts index c590cf8f34ee0..945fd2065ce78 100644 --- a/src/plugins/ui_actions/public/actions/action_definition.ts +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -18,55 +18,46 @@ */ import { UiComponent } from 'src/plugins/kibana_utils/common'; -import { ActionType, ActionContextMapping } from '../types'; -export interface ActionDefinition { +/** + * Represents something that can be displayed to user in UI. + */ +export interface Presentable { /** - * Determined the order when there is more than one action matched to a trigger. - * Higher numbers are displayed first. + * ID that uniquely identifies this object. */ - order?: number; + readonly id: string; /** - * A unique identifier for this action instance. + * Determines the display order in relation to other items. Higher numbers are + * displayed first. */ - id?: string; + readonly order: number; /** - * The action type is what determines the context shape. + * `UiComponent` to render when displaying this entity as a context menu item. + * If not provided, `getDisplayName` will be used instead. */ - readonly type: T; + readonly MenuItem?: UiComponent<{ context: Context }>; /** * Optional EUI icon type that can be displayed along with the title. */ - getIconType?(context: ActionContextMapping[T]): string; + getIconType(context: Context): string | undefined; /** * Returns a title to be displayed to the user. - * @param context - */ - getDisplayName?(context: ActionContextMapping[T]): string; - - /** - * `UiComponent` to render when displaying this action as a context menu item. - * If not provided, `getDisplayName` will be used instead. - */ - MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>; - - /** - * Returns a promise that resolves to true if this action is compatible given the context, - * otherwise resolves to false. */ - isCompatible?(context: ActionContextMapping[T]): Promise; + getDisplayName(context: Context): string; /** - * If this returns something truthy, this is used in addition to the `execute` method when clicked. + * This method should return a link if this item can be clicked on. */ - getHref?(context: ActionContextMapping[T]): string | undefined; + getHref?(context: Context): string | undefined; /** - * Executes the action. + * Returns a promise that resolves to true if this item is compatible given + * the context and should be displayed to user, otherwise resolves to false. */ - execute(context: ActionContextMapping[T]): Promise; + isCompatible(context: Context): Promise; } diff --git a/src/plugins/ui_actions/scripts/storybook.js b/src/plugins/ui_actions/scripts/storybook.js new file mode 100644 index 0000000000000..cb2eda610170d --- /dev/null +++ b/src/plugins/ui_actions/scripts/storybook.js @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'path'; + +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'ui_actions', + storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], +}); diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx index 18ceec652392d..8ddb2e1a4803b 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx @@ -70,11 +70,10 @@ export class EmbeddableExplorerPublicPlugin const sayHelloAction = new SayHelloAction(alert); const sendMessageAction = createSendMessageAction(core.overlays); - plugins.uiActions.registerAction(helloWorldAction); plugins.uiActions.registerAction(sayHelloAction); plugins.uiActions.registerAction(sendMessageAction); - plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction); plugins.__LEGACY.onRenderComplete(() => { const root = document.getElementById(REACT_ROOT_ID); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx index 8395fddece2a4..7c7cc689d05e5 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx @@ -62,5 +62,4 @@ function createSamplePanelAction() { } const action = createSamplePanelAction(); -npSetup.plugins.uiActions.registerAction(action); -npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); +npSetup.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts index 4b09be4db8a60..e034fbe320608 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts @@ -33,5 +33,4 @@ export const createSamplePanelLink = (): Action => }); const action = createSamplePanelLink(); -npStart.plugins.uiActions.registerAction(action); -npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); +npStart.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 2a28e349ace99..784b5a5a42ace 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -9,6 +9,7 @@ "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", "xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication", + "xpack.dashboard": "plugins/dashboard_enhanced", "xpack.dashboardMode": "legacy/plugins/dashboard_mode", "xpack.data": "plugins/data_enhanced", "xpack.drilldowns": "plugins/drilldowns", diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss index 2ba6f9baca90d..87ec3f8fc7ec1 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss @@ -1,8 +1,3 @@ -.auaActionWizard__selectedActionFactoryContainer { - background-color: $euiColorLightestShade; - padding: $euiSize; -} - .auaActionWizard__actionFactoryItem { .euiKeyPadMenuItem__label { height: #{$euiSizeXL}; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx index 62f16890cade2..9c73f07289dc9 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx @@ -6,28 +6,26 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data'; +import { Demo, dashboardFactory, urlFactory } from './test_data'; storiesOf('components/ActionWizard', module) - .add('default', () => ( - - )) + .add('default', () => ) .add('Only one factory is available', () => ( // to make sure layout doesn't break - + )) .add('Long list of action factories', () => ( // to make sure layout doesn't break )); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx index aea47be693b8f..cc56714fcb2f8 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx @@ -8,21 +8,14 @@ import React from 'react'; import { cleanup, fireEvent, render } from '@testing-library/react/pure'; import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard'; -import { - dashboardDrilldownActionFactory, - dashboards, - Demo, - urlDrilldownActionFactory, -} from './test_data'; +import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data'; // TODO: afterEach is not available for it globally during setup // https://github.com/elastic/kibana/issues/59469 afterEach(cleanup); test('Pick and configure action', () => { - const screen = render( - - ); + const screen = render(); // check that all factories are displayed to pick expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2); @@ -47,7 +40,7 @@ test('Pick and configure action', () => { }); test('If only one actions factory is available then actionFactory selection is emitted without user input', () => { - const screen = render(); + const screen = render(); // check that no factories are displayed to pick from expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument(); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx index 41ef863c00e44..846f6d41eb30d 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -16,40 +16,23 @@ import { } from '@elastic/eui'; import { txtChangeButton } from './i18n'; import './action_wizard.scss'; +import { ActionFactory } from '../../services'; -// TODO: this interface is temporary for just moving forward with the component -// and it will be imported from the ../ui_actions when implemented properly -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type ActionBaseConfig = {}; -export interface ActionFactory { - type: string; // TODO: type should be tied to Action and ActionByType - displayName: string; - iconType?: string; - wizard: React.FC>; - createConfig: () => Config; - isValid: (config: Config) => boolean; -} - -export interface ActionFactoryWizardProps { - config?: Config; - - /** - * Callback called when user updates the config in UI. - */ - onConfig: (config: Config) => void; -} +type ActionBaseConfig = object; +type ActionFactoryBaseContext = object; export interface ActionWizardProps { /** * List of available action factories */ - actionFactories: Array>; // any here to be able to pass array of ActionFactory with different configs + actionFactories: ActionFactory[]; /** * Currently selected action factory * undefined - is allowed and means that non is selected */ currentActionFactory?: ActionFactory; + /** * Action factory selected changed * null - means user click "change" and removed action factory selection @@ -65,6 +48,11 @@ export interface ActionWizardProps { * config changed */ onConfigChange: (config: ActionBaseConfig) => void; + + /** + * Context will be passed into ActionFactory's methods + */ + context: ActionFactoryBaseContext; } export const ActionWizard: React.FC = ({ @@ -73,6 +61,7 @@ export const ActionWizard: React.FC = ({ onActionFactoryChange, onConfigChange, config, + context, }) => { // auto pick action factory if there is only 1 available if (!currentActionFactory && actionFactories.length === 1) { @@ -87,6 +76,7 @@ export const ActionWizard: React.FC = ({ onDeselect={() => { onActionFactoryChange(null); }} + context={context} config={config} onConfigChange={newConfig => { onConfigChange(newConfig); @@ -97,6 +87,7 @@ export const ActionWizard: React.FC = ({ return ( { onActionFactoryChange(actionFactory); @@ -105,10 +96,11 @@ export const ActionWizard: React.FC = ({ ); }; -interface SelectedActionFactoryProps { - actionFactory: ActionFactory; - config: Config; - onConfigChange: (config: Config) => void; +interface SelectedActionFactoryProps { + actionFactory: ActionFactory; + config: ActionBaseConfig; + context: ActionFactoryBaseContext; + onConfigChange: (config: ActionBaseConfig) => void; showDeselect: boolean; onDeselect: () => void; } @@ -121,28 +113,28 @@ const SelectedActionFactory: React.FC = ({ showDeselect, onConfigChange, config, + context, }) => { return (
- {actionFactory.iconType && ( + {actionFactory.getIconType(context) && ( - + )} -

{actionFactory.displayName}

+

{actionFactory.getDisplayName(context)}

{showDeselect && ( - onDeselect()}> + onDeselect()}> {txtChangeButton} @@ -151,10 +143,11 @@ const SelectedActionFactory: React.FC = ({
- {actionFactory.wizard({ - config, - onConfig: onConfigChange, - })} +
); @@ -162,6 +155,7 @@ const SelectedActionFactory: React.FC = ({ interface ActionFactorySelectorProps { actionFactories: ActionFactory[]; + context: ActionFactoryBaseContext; onActionFactorySelected: (actionFactory: ActionFactory) => void; } @@ -170,6 +164,7 @@ export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item'; const ActionFactorySelector: React.FC = ({ actionFactories, onActionFactorySelected, + context, }) => { if (actionFactories.length === 0) { // this is not user facing, as it would be impossible to get into this state @@ -178,19 +173,23 @@ const ActionFactorySelector: React.FC = ({ } return ( - - {actionFactories.map(actionFactory => ( - onActionFactorySelected(actionFactory)} - > - {actionFactory.iconType && } - - ))} + + {[...actionFactories] + .sort((f1, f2) => f1.order - f2.order) + .map(actionFactory => ( + + onActionFactorySelected(actionFactory)} + > + {actionFactory.getIconType(context) && ( + + )} + + + ))} ); }; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts index 641f25176264a..a315184bf68ef 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts @@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n'; export const txtChangeButton = i18n.translate( 'xpack.advancedUiActions.components.actionWizard.changeButton', { - defaultMessage: 'change', + defaultMessage: 'Change', } ); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts index ed224248ec4cd..a189afbf956ee 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ActionFactory, ActionWizard } from './action_wizard'; +export { ActionWizard } from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx index 8ecdde681069e..167cb130fdb4a 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -6,124 +6,161 @@ import React, { useState } from 'react'; import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; -import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { ActionWizard } from './action_wizard'; +import { ActionFactoryDefinition, ActionFactory } from '../../services'; +import { CollectConfigProps } from '../../util'; + +type ActionBaseConfig = object; export const dashboards = [ { id: 'dashboard1', title: 'Dashboard 1' }, { id: 'dashboard2', title: 'Dashboard 2' }, ]; -export const dashboardDrilldownActionFactory: ActionFactory<{ +interface DashboardDrilldownConfig { dashboardId?: string; - useCurrentDashboardFilters: boolean; - useCurrentDashboardDataRange: boolean; -}> = { - type: 'Dashboard', - displayName: 'Go to Dashboard', - iconType: 'dashboardApp', + useCurrentFilters: boolean; + useCurrentDateRange: boolean; +} + +function DashboardDrilldownCollectConfig(props: CollectConfigProps) { + const config = props.config ?? { + dashboardId: undefined, + useCurrentFilters: true, + useCurrentDateRange: true, + }; + return ( + <> + + ({ value: id, text: title }))} + value={config.dashboardId} + onChange={e => { + props.onConfig({ ...config, dashboardId: e.target.value }); + }} + /> + + + + props.onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + /> + + + + props.onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + + + ); +} + +export const dashboardDrilldownActionFactory: ActionFactoryDefinition< + DashboardDrilldownConfig, + any, + any +> = { + id: 'Dashboard', + getDisplayName: () => 'Go to Dashboard', + getIconType: () => 'dashboardApp', createConfig: () => { return { dashboardId: undefined, - useCurrentDashboardDataRange: true, - useCurrentDashboardFilters: true, + useCurrentFilters: true, + useCurrentDateRange: true, }; }, - isValid: config => { + isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => { if (!config.dashboardId) return false; return true; }, - wizard: props => { - const config = props.config ?? { - dashboardId: undefined, - useCurrentDashboardDataRange: true, - useCurrentDashboardFilters: true, - }; - return ( - <> - - ({ value: id, text: title }))} - value={config.dashboardId} - onChange={e => { - props.onConfig({ ...config, dashboardId: e.target.value }); - }} - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardFilters: !config.useCurrentDashboardFilters, - }) - } - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange, - }) - } - /> - - - ); + CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig), + + isCompatible(context?: object): Promise { + return Promise.resolve(true); }, + order: 0, + create: () => ({ + id: 'test', + execute: async () => alert('Navigate to dashboard!'), + }), }; -export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = { - type: 'Url', - displayName: 'Go to URL', - iconType: 'link', +export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory); + +interface UrlDrilldownConfig { + url: string; + openInNewTab: boolean; +} +function UrlDrilldownCollectConfig(props: CollectConfigProps) { + const config = props.config ?? { + url: '', + openInNewTab: false, + }; + return ( + <> + + props.onConfig({ ...config, url: event.target.value })} + /> + + + props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); +} +export const urlDrilldownActionFactory: ActionFactoryDefinition = { + id: 'Url', + getDisplayName: () => 'Go to URL', + getIconType: () => 'link', createConfig: () => { return { url: '', openInNewTab: false, }; }, - isValid: config => { + isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => { if (!config.url) return false; return true; }, - wizard: props => { - const config = props.config ?? { - url: '', - openInNewTab: false, - }; - return ( - <> - - props.onConfig({ ...config, url: event.target.value })} - /> - - - props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - - - ); + CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig), + + order: 10, + isCompatible(context?: object): Promise { + return Promise.resolve(true); }, + create: () => null as any, }; +export const urlFactory = new ActionFactory(urlDrilldownActionFactory); + export function Demo({ actionFactories }: { actionFactories: Array> }) { const [state, setState] = useState<{ currentActionFactory?: ActionFactory; @@ -157,14 +194,15 @@ export function Demo({ actionFactories }: { actionFactories: Array

-
Action Factory Type: {state.currentActionFactory?.type}
+
Action Factory Id: {state.currentActionFactory?.id}
Action Factory Config: {JSON.stringify(state.config)}
Is config valid:{' '} - {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)} + {JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)}
); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx b/x-pack/plugins/advanced_ui_actions/public/components/index.ts similarity index 87% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx rename to x-pack/plugins/advanced_ui_actions/public/components/index.ts index 3be289fe6d46e..236b1a6ec4611 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './drilldown_picker'; +export * from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx index 325a5ddc10179..c0cd8d5540db2 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx @@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType { + implements Plugin { constructor(initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup, { uiActions }: SetupDependencies): Setup {} + public setup(core: CoreSetup, { uiActions }: SetupDependencies): SetupContract { + return { + ...uiActions, + }; + } - public start(core: CoreStart, { uiActions }: StartDependencies): Start { + public start(core: CoreStart, { uiActions }: StartDependencies): StartContract { const dateFormat = core.uiSettings.get('dateFormat') as string; const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[]; const { openModal } = createReactOverlays(core); @@ -66,16 +72,18 @@ export class AdvancedUiActionsPublicPlugin dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, timeRangeAction); const timeRangeBadge = new CustomTimeRangeBadge({ openModal, dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeBadge); - uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + uiActions.addTriggerAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + + return { + ...uiActions, + }; } public stop() {} diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts new file mode 100644 index 0000000000000..66e2a4eafa880 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable */ + +export { + ActionFactory +} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts new file mode 100644 index 0000000000000..f8669a4bf813f --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable */ + +export { + ActionFactoryDefinition +} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory_definition'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts new file mode 100644 index 0000000000000..db5bb3aa62a16 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './action_factory_definition'; +export * from './action_factory'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts b/x-pack/plugins/advanced_ui_actions/public/services/index.ts similarity index 84% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts rename to x-pack/plugins/advanced_ui_actions/public/services/index.ts index ce235043b4ef6..0f8b4c8d8f409 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/services/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './flyout_create_drilldown'; +export * from './action_factory_service'; diff --git a/x-pack/plugins/advanced_ui_actions/public/util/index.ts b/x-pack/plugins/advanced_ui_actions/public/util/index.ts new file mode 100644 index 0000000000000..fd3ab89973348 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/util/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + UiActionsConfigurable as Configurable, + UiActionsCollectConfigProps as CollectConfigProps, +} from '../../../../../src/plugins/ui_actions/public'; diff --git a/x-pack/plugins/dashboard_enhanced/README.md b/x-pack/plugins/dashboard_enhanced/README.md new file mode 100644 index 0000000000000..d9296ae158621 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/README.md @@ -0,0 +1 @@ +# X-Pack part of Dashboard app diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json new file mode 100644 index 0000000000000..acbca5c33295c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "dashboardEnhanced", + "version": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["uiActions", "embeddable", "dashboard", "drilldowns"], + "configPath": ["xpack", "dashboardEnhanced"] +} diff --git a/x-pack/plugins/dashboard_enhanced/public/components/README.md b/x-pack/plugins/dashboard_enhanced/public/components/README.md new file mode 100644 index 0000000000000..8081f8a2451cf --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/README.md @@ -0,0 +1,5 @@ +# Presentation React components + +Here we keep reusable *presentation* (aka *dumb*) React components—these +components should not be connected to state and ideally should not know anything +about Kibana. diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx new file mode 100644 index 0000000000000..8e204b044a136 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { DashboardDrilldownConfig } from '.'; + +export const dashboards = [ + { id: 'dashboard1', title: 'Dashboard 1' }, + { id: 'dashboard2', title: 'Dashboard 2' }, + { id: 'dashboard3', title: 'Dashboard 3' }, +]; + +const InteractiveDemo: React.FC = () => { + const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); + const [currentFilters, setCurrentFilters] = React.useState(false); + const [keepRange, setKeepRange] = React.useState(false); + + return ( + setActiveDashboardId(id)} + onCurrentFiltersToggle={() => setCurrentFilters(old => !old)} + onKeepRangeToggle={() => setKeepRange(old => !old)} + /> + ); +}; + +storiesOf('components/DashboardDrilldownConfig', module) + .add('default', () => ( + console.log('onDashboardSelect', e)} + /> + )) + .add('with switches', () => ( + console.log('onDashboardSelect', e)} + onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} + onKeepRangeToggle={() => console.log('onKeepRangeToggle')} + /> + )) + .add('interactive demo', () => ); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx new file mode 100644 index 0000000000000..911ff6f632635 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +test.todo('renders list of dashboards'); +test.todo('renders correct selected dashboard'); +test.todo('can change dashboard'); +test.todo('can toggle "use current filters" switch'); +test.todo('can toggle "date range" switch'); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx new file mode 100644 index 0000000000000..b45ba602b9bb1 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; +import { txtChooseDestinationDashboard } from './i18n'; + +export interface DashboardItem { + id: string; + title: string; +} + +export interface DashboardDrilldownConfigProps { + activeDashboardId?: string; + dashboards: DashboardItem[]; + currentFilters?: boolean; + keepRange?: boolean; + onDashboardSelect: (dashboardId: string) => void; + onCurrentFiltersToggle?: () => void; + onKeepRangeToggle?: () => void; +} + +export const DashboardDrilldownConfig: React.FC = ({ + activeDashboardId, + dashboards, + currentFilters, + keepRange, + onDashboardSelect, + onCurrentFiltersToggle, + onKeepRangeToggle, +}) => { + // TODO: use i18n below. + return ( + <> + + ({ value: id, text: title }))} + value={activeDashboardId} + onChange={e => onDashboardSelect(e.target.value)} + /> + + {!!onCurrentFiltersToggle && ( + + + + )} + {!!onKeepRangeToggle && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts new file mode 100644 index 0000000000000..38fe6dd150853 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtChooseDestinationDashboard = i18n.translate( + 'xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard', + { + defaultMessage: 'Choose destination dashboard', + } +); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts new file mode 100644 index 0000000000000..b9a64a3cc17e6 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/index.ts b/x-pack/plugins/dashboard_enhanced/public/components/index.ts new file mode 100644 index 0000000000000..b9a64a3cc17e6 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/index.ts b/x-pack/plugins/dashboard_enhanced/public/index.ts new file mode 100644 index 0000000000000..53540a4a1ad2e --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { DashboardEnhancedPlugin } from './plugin'; + +export { + SetupContract as DashboardEnhancedSetupContract, + SetupDependencies as DashboardEnhancedSetupDependencies, + StartContract as DashboardEnhancedStartContract, + StartDependencies as DashboardEnhancedStartDependencies, +} from './plugin'; + +export function plugin(context: PluginInitializerContext) { + return new DashboardEnhancedPlugin(context); +} diff --git a/x-pack/plugins/dashboard_enhanced/public/mocks.ts b/x-pack/plugins/dashboard_enhanced/public/mocks.ts new file mode 100644 index 0000000000000..67dc1fd97d521 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/mocks.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DashboardEnhancedSetupContract, DashboardEnhancedStartContract } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = {}; + + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = {}; + + return startContract; +}; + +export const dashboardEnhancedPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/plugin.ts b/x-pack/plugins/dashboard_enhanced/public/plugin.ts new file mode 100644 index 0000000000000..30b3f3c080f49 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/plugin.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; +import { DashboardDrilldownsService } from './services'; +import { DrilldownsSetup, DrilldownsStart } from '../../drilldowns/public'; + +export interface SetupDependencies { + uiActions: UiActionsSetup; + drilldowns: DrilldownsSetup; +} + +export interface StartDependencies { + uiActions: UiActionsStart; + drilldowns: DrilldownsStart; +} + +// eslint-disable-next-line +export interface SetupContract {} + +// eslint-disable-next-line +export interface StartContract {} + +export class DashboardEnhancedPlugin + implements Plugin { + public readonly drilldowns = new DashboardDrilldownsService(); + public readonly config: { drilldowns: { enabled: boolean } }; + + constructor(protected readonly context: PluginInitializerContext) { + this.config = context.config.get(); + } + + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + this.drilldowns.bootstrap(core, plugins, { + enableDrilldowns: this.config.drilldowns.enabled, + }); + + return {}; + } + + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx new file mode 100644 index 0000000000000..31ee9e29938cb --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, +} from './flyout_create_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; +import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public'; +import { MockEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); +const uiActions = uiActionsPluginMock.createStartContract(); + +const actionParams: OpenFlyoutAddDrilldownParams = { + drilldowns: () => Promise.resolve(drilldowns), + overlays: () => Promise.resolve(overlays), +}; + +test('should create', () => { + expect(() => new FlyoutCreateDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getIconType() === 'string').toBe( + true + ); +}); + +describe('isCompatible', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + + function checkCompatibility(params: { + isEdit: boolean; + withUiActions: boolean; + isValueClickTriggerSupported: boolean; + }): Promise { + return drilldownAction.isCompatible({ + embeddable: new MockEmbeddable( + { id: '', viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW }, + { + supportedTriggers: (params.isValueClickTriggerSupported + ? ['VALUE_CLICK_TRIGGER'] + : []) as Array, + uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support + } + ), + }); + } + + test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: true, + isValueClickTriggerSupported: true, + }) + ).toBe(true); + }); + + test('not compatible if dynamicUiActions disabled', async () => { + expect( + await checkCompatibility({ + withUiActions: false, + isEdit: true, + isValueClickTriggerSupported: true, + }) + ).toBe(false); + }); + + test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: true, + isValueClickTriggerSupported: false, + }) + ).toBe(false); + }); + + test('not compatible if in view mode', async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: false, + isValueClickTriggerSupported: true, + }) + ).toBe(false); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager"` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + await drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, { uiActions }), + }); + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx new file mode 100644 index 0000000000000..00e74ea570a11 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { CoreStart } from 'src/core/public'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { DrilldownsStart } from '../../../../../../drilldowns/public'; +import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; + +export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; + +export interface OpenFlyoutAddDrilldownParams { + overlays: () => Promise; + drilldowns: () => Promise; +} + +export class FlyoutCreateDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; + public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; + public order = 12; + + constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} + + public getDisplayName() { + return i18n.translate('xpack.dashboard.FlyoutCreateDrilldownAction.displayName', { + defaultMessage: 'Create drilldown', + }); + } + + public getIconType() { + return 'plusInCircle'; + } + + private isEmbeddableCompatible(context: EmbeddableContext) { + if (!context.embeddable.dynamicActions) return false; + const supportedTriggers = context.embeddable.supportedTriggers(); + if (!supportedTriggers || !supportedTriggers.length) return false; + return supportedTriggers.indexOf('VALUE_CLICK_TRIGGER') > -1; + } + + public async isCompatible(context: EmbeddableContext) { + const isEditMode = context.embeddable.getInput().viewMode === 'edit'; + return isEditMode && this.isEmbeddableCompatible(context); + } + + public async execute(context: EmbeddableContext) { + const overlays = await this.params.overlays(); + const drilldowns = await this.params.drilldowns(); + const dynamicActionManager = context.embeddable.dynamicActions; + + if (!dynamicActionManager) { + throw new Error(`Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager`); + } + + const handle = overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'create'} + dynamicActionManager={dynamicActionManager} + /> + ), + { + ownFocus: true, + } + ); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts new file mode 100644 index 0000000000000..4d2db209fc961 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, + OPEN_FLYOUT_ADD_DRILLDOWN, +} from './flyout_create_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx new file mode 100644 index 0000000000000..a3f11eb976f90 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; +import { MockEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); +const uiActions = uiActionsPluginMock.createStartContract(); + +const actionParams: FlyoutEditDrilldownParams = { + drilldowns: () => Promise.resolve(drilldowns), + overlays: () => Promise.resolve(overlays), +}; + +test('should create', () => { + expect(() => new FlyoutEditDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getIconType() === 'string').toBe(true); +}); + +test('MenuItem exists', () => { + expect(new FlyoutEditDrilldownAction(actionParams).MenuItem).toBeDefined(); +}); + +describe('isCompatible', () => { + const drilldownAction = new FlyoutEditDrilldownAction(actionParams); + + function checkCompatibility(params: { + isEdit: boolean; + withUiActions: boolean; + }): Promise { + return drilldownAction.isCompatible({ + embeddable: new MockEmbeddable( + { + id: '', + viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW, + }, + { + uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support + } + ), + }); + } + + // TODO: need proper DynamicActionsMock and ActionFactory mock + test.todo('compatible if dynamicUiActions enabled, in edit view, and have at least 1 drilldown'); + + test('not compatible if dynamicUiActions disabled', async () => { + expect( + await checkCompatibility({ + withUiActions: false, + isEdit: true, + }) + ).toBe(false); + }); + + test('not compatible if no drilldowns', async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: true, + }) + ).toBe(false); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutEditDrilldownAction(actionParams); + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't execute FlyoutEditDrilldownAction without dynamicActionsManager"` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + await drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, { uiActions }), + }); + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx new file mode 100644 index 0000000000000..816b757592a72 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { CoreStart } from 'src/core/public'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { + reactToUiComponent, + toMountPoint, +} from '../../../../../../../../src/plugins/kibana_react/public'; +import { EmbeddableContext, ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { DrilldownsStart } from '../../../../../../drilldowns/public'; +import { txtDisplayName } from './i18n'; +import { MenuItem } from './menu_item'; + +export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; + +export interface FlyoutEditDrilldownParams { + overlays: () => Promise; + drilldowns: () => Promise; +} + +export class FlyoutEditDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; + public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; + public order = 10; + + constructor(protected readonly params: FlyoutEditDrilldownParams) {} + + public getDisplayName() { + return txtDisplayName; + } + + public getIconType() { + return 'list'; + } + + MenuItem = reactToUiComponent(MenuItem); + + public async isCompatible({ embeddable }: EmbeddableContext) { + if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; + if (!embeddable.dynamicActions) return false; + return embeddable.dynamicActions.state.get().events.length > 0; + } + + public async execute(context: EmbeddableContext) { + const overlays = await this.params.overlays(); + const drilldowns = await this.params.drilldowns(); + const dynamicActionManager = context.embeddable.dynamicActions; + if (!dynamicActionManager) { + throw new Error(`Can't execute FlyoutEditDrilldownAction without dynamicActionsManager`); + } + + const handle = overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'manage'} + dynamicActionManager={dynamicActionManager} + /> + ), + { + ownFocus: true, + } + ); + } +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts similarity index 64% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts index ceabc6d3a9aa5..4e2e5eb7092e4 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; -export const txtCreateDrilldown = i18n.translate( - 'xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown', +export const txtDisplayName = i18n.translate( + 'xpack.dashboard.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Create drilldown', + defaultMessage: 'Manage drilldowns', } ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx new file mode 100644 index 0000000000000..3e1b37f270708 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + FlyoutEditDrilldownAction, + FlyoutEditDrilldownParams, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './flyout_edit_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx new file mode 100644 index 0000000000000..be693fadf9282 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, cleanup, act } from '@testing-library/react/pure'; +import { MenuItem } from './menu_item'; +import { createStateContainer } from '../../../../../../../../src/plugins/kibana_utils/common'; +import { DynamicActionManager } from '../../../../../../../../src/plugins/ui_actions/public'; +import { IEmbeddable } from '../../../../../../../../src/plugins/embeddable/public/lib/embeddables'; +import '@testing-library/jest-dom'; + +afterEach(cleanup); + +test('', () => { + const state = createStateContainer<{ events: object[] }>({ events: [] }); + const { getByText, queryByText } = render( + + ); + + expect(getByText(/manage drilldowns/i)).toBeInTheDocument(); + expect(queryByText('0')).not.toBeInTheDocument(); + + act(() => { + state.set({ events: [{}] }); + }); + + expect(queryByText('1')).toBeInTheDocument(); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx new file mode 100644 index 0000000000000..4f99fca511b07 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiNotificationBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; +import { txtDisplayName } from './i18n'; +import { useContainerState } from '../../../../../../../../src/plugins/kibana_utils/common'; + +export const MenuItem: React.FC<{ context: EmbeddableContext }> = ({ context }) => { + if (!context.embeddable.dynamicActions) + throw new Error('Flyout edit drillldown context menu item requires `dynamicActions`'); + + const { events } = useContainerState(context.embeddable.dynamicActions.state); + const count = events.length; + + return ( + + {txtDisplayName} + {count > 0 && ( + + {count} + + )} + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/actions/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/actions/index.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts new file mode 100644 index 0000000000000..9b156b0ba85b4 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Embeddable, EmbeddableInput } from '../../../../../../../src/plugins/embeddable/public/'; +import { + TriggerContextMapping, + UiActionsStart, +} from '../../../../../../../src/plugins/ui_actions/public'; + +export class MockEmbeddable extends Embeddable { + public readonly type = 'mock'; + private readonly triggers: Array = []; + constructor( + initialInput: EmbeddableInput, + params: { uiActions?: UiActionsStart; supportedTriggers?: Array } + ) { + super(initialInput, {}, undefined, params); + this.triggers = params.supportedTriggers ?? []; + } + public render(node: HTMLElement) {} + public reload() {} + public supportedTriggers(): Array { + return this.triggers; + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts new file mode 100644 index 0000000000000..4bdf03dff3531 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { SetupDependencies } from '../../plugin'; +import { + CONTEXT_MENU_TRIGGER, + EmbeddableContext, +} from '../../../../../../src/plugins/embeddable/public'; +import { + FlyoutCreateDrilldownAction, + FlyoutEditDrilldownAction, + OPEN_FLYOUT_ADD_DRILLDOWN, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './actions'; +import { DrilldownsStart } from '../../../../drilldowns/public'; +import { DashboardToDashboardDrilldown } from './dashboard_to_dashboard_drilldown'; + +declare module '../../../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [OPEN_FLYOUT_ADD_DRILLDOWN]: EmbeddableContext; + [OPEN_FLYOUT_EDIT_DRILLDOWN]: EmbeddableContext; + } +} + +interface BootstrapParams { + enableDrilldowns: boolean; +} + +export class DashboardDrilldownsService { + bootstrap( + core: CoreSetup<{ drilldowns: DrilldownsStart }>, + plugins: SetupDependencies, + { enableDrilldowns }: BootstrapParams + ) { + if (enableDrilldowns) { + this.setupDrilldowns(core, plugins); + } + } + + setupDrilldowns(core: CoreSetup<{ drilldowns: DrilldownsStart }>, plugins: SetupDependencies) { + const overlays = async () => (await core.getStartServices())[0].overlays; + const drilldowns = async () => (await core.getStartServices())[1].drilldowns; + const savedObjects = async () => (await core.getStartServices())[0].savedObjects.client; + + const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays, drilldowns }); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); + + const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays, drilldowns }); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); + + const dashboardToDashboardDrilldown = new DashboardToDashboardDrilldown({ + savedObjects, + }); + plugins.drilldowns.registerDrilldown(dashboardToDashboardDrilldown); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx new file mode 100644 index 0000000000000..95101605ce468 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +test.todo('displays all dashboard in a list'); +test.todo('does not display dashboard on which drilldown is being created'); +test.todo('updates config object correctly'); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx new file mode 100644 index 0000000000000..e463cc38b6fbf --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { CollectConfigProps } from './types'; +import { DashboardDrilldownConfig } from '../../../components/dashboard_drilldown_config'; +import { Params } from './drilldown'; + +export interface CollectConfigContainerProps extends CollectConfigProps { + params: Params; +} + +export const CollectConfigContainer: React.FC = ({ + config, + onConfig, + params: { savedObjects }, +}) => { + const [dashboards] = useState([ + { id: 'dashboard1', title: 'Dashboard 1' }, + { id: 'dashboard2', title: 'Dashboard 2' }, + { id: 'dashboard3', title: 'Dashboard 3' }, + { id: 'dashboard4', title: 'Dashboard 4' }, + ]); + + useEffect(() => { + // TODO: Load dashboards... + }, [savedObjects]); + + return ( + { + onConfig({ ...config, dashboardId }); + }} + onCurrentFiltersToggle={() => + onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + onKeepRangeToggle={() => + onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts new file mode 100644 index 0000000000000..e2a530b156da5 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DASHBOARD_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx new file mode 100644 index 0000000000000..0fb60bb1064a1 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +describe('.isConfigValid()', () => { + test.todo('returns false for incorrect config'); + test.todo('returns true for incorrect config'); +}); + +describe('.execute()', () => { + test.todo('navigates to correct dashboard'); + test.todo( + 'when user chooses to keep current filters, current fileters are set on destination dashboard' + ); + test.todo( + 'when user chooses to keep current time range, current time range is set on destination dashboard' + ); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx new file mode 100644 index 0000000000000..9d2a378f08acd --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { CoreStart } from 'src/core/public'; +import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; +import { PlaceContext, ActionContext, Config, CollectConfigProps } from './types'; +import { CollectConfigContainer } from './collect_config'; +import { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +import { DrilldownDefinition as Drilldown } from '../../../../../drilldowns/public'; +import { txtGoToDashboard } from './i18n'; + +export interface Params { + savedObjects: () => Promise; +} + +export class DashboardToDashboardDrilldown + implements Drilldown { + constructor(protected readonly params: Params) {} + + public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN; + + public readonly order = 100; + + public readonly getDisplayName = () => txtGoToDashboard; + + public readonly euiIcon = 'dashboardApp'; + + private readonly ReactCollectConfig: React.FC = props => ( + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + dashboardId: '123', + useCurrentFilters: true, + useCurrentDateRange: true, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + if (!config.dashboardId) return false; + return true; + }; + + public readonly execute = () => { + alert('Go to another dashboard!'); + }; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts new file mode 100644 index 0000000000000..98b746bafd24a --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtGoToDashboard = i18n.translate('xpack.dashboard.drilldown.goToDashboard', { + defaultMessage: 'Go to Dashboard', +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts new file mode 100644 index 0000000000000..9daa485bb6e6c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +export { + DashboardToDashboardDrilldown, + Params as DashboardToDashboardDrilldownParams, +} from './drilldown'; +export { + PlaceContext as DashboardToDashboardPlaceContext, + ActionContext as DashboardToDashboardActionContext, + Config as DashboardToDashboardConfig, +} from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts new file mode 100644 index 0000000000000..398a259491e3e --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EmbeddableVisTriggerContext, + EmbeddableContext, +} from '../../../../../../../src/plugins/embeddable/public'; +import { UiActionsCollectConfigProps } from '../../../../../../../src/plugins/ui_actions/public'; + +export type PlaceContext = EmbeddableContext; +export type ActionContext = EmbeddableVisTriggerContext; + +export interface Config { + dashboardId?: string; + useCurrentFilters: boolean; + useCurrentDateRange: boolean; +} + +export type CollectConfigProps = UiActionsCollectConfigProps; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts new file mode 100644 index 0000000000000..7be8f1c65da12 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dashboard_drilldowns_services'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/index.ts new file mode 100644 index 0000000000000..8cc3e12906531 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js similarity index 53% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx rename to x-pack/plugins/dashboard_enhanced/scripts/storybook.js index 5627a5d6f4522..f2cbe4135f4cb 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx +++ b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DrilldownPicker } from '.'; +import { join } from 'path'; -storiesOf('components/DrilldownPicker', module).add('default', () => { - return ; +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'dashboard_enhanced', + storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], }); diff --git a/x-pack/plugins/dashboard_enhanced/server/config.ts b/x-pack/plugins/dashboard_enhanced/server/config.ts new file mode 100644 index 0000000000000..b75c95d5f8832 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/server/config.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '../../../../src/core/server'; + +export const configSchema = schema.object({ + drilldowns: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), +}); + +export type ConfigSchema = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + drilldowns: true, + }, +}; diff --git a/x-pack/plugins/dashboard_enhanced/server/index.ts b/x-pack/plugins/dashboard_enhanced/server/index.ts new file mode 100644 index 0000000000000..e361b9fb075ed --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/server/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { config } from './config'; + +export const plugin = () => ({ + setup() {}, + start() {}, +}); diff --git a/x-pack/plugins/drilldowns/kibana.json b/x-pack/plugins/drilldowns/kibana.json index b951c7dc1fc87..8372d87166364 100644 --- a/x-pack/plugins/drilldowns/kibana.json +++ b/x-pack/plugins/drilldowns/kibana.json @@ -3,8 +3,5 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": [ - "uiActions", - "embeddable" - ] + "requiredPlugins": ["uiActions", "embeddable", "advancedUiActions"] } diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx deleted file mode 100644 index 4834cc8081374..0000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldown } from '../../components/flyout_create_drilldown'; - -export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; - -export interface FlyoutCreateDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface OpenFlyoutAddDrilldownParams { - overlays: () => Promise; -} - -export class FlyoutCreateDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; - public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} - - public getDisplayName() { - return i18n.translate('xpack.drilldowns.FlyoutCreateDrilldownAction.displayName', { - defaultMessage: 'Create drilldown', - }); - } - - public getIconType() { - return 'plusInCircle'; - } - - public async isCompatible({ embeddable }: FlyoutCreateDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit'; - } - - public async execute(context: FlyoutCreateDrilldownActionContext) { - const overlays = await this.params.overlays(); - const handle = overlays.openFlyout( - toMountPoint( handle.close()} />) - ); - } -} diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx deleted file mode 100644 index f109da94fcaca..0000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { EuiNotificationBadge } from '@elastic/eui'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { - toMountPoint, - reactToUiComponent, -} from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FormCreateDrilldown } from '../../components/form_create_drilldown'; - -export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; - -export interface FlyoutEditDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface FlyoutEditDrilldownParams { - overlays: () => Promise; -} - -const displayName = i18n.translate('xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Manage drilldowns', -}); - -// mocked data -const drilldrownCount = 2; - -export class FlyoutEditDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; - public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: FlyoutEditDrilldownParams) {} - - public getDisplayName() { - return displayName; - } - - public getIconType() { - return 'list'; - } - - private ReactComp: React.FC<{ context: FlyoutEditDrilldownActionContext }> = () => { - return ( - <> - {displayName}{' '} - - {drilldrownCount} - - - ); - }; - - MenuItem = reactToUiComponent(this.ReactComp); - - public async isCompatible({ embeddable }: FlyoutEditDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit' && drilldrownCount > 0; - } - - public async execute({ embeddable }: FlyoutEditDrilldownActionContext) { - const overlays = await this.params.overlays(); - overlays.openFlyout(toMountPoint()); - } -} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..16b4d3a25d9e5 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { mockDynamicActionManager } from './test_data'; + +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage: new Storage(new StubBrowserStorage()), + notifications: { + toasts: { + addError: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + addSuccess: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + } as any, + }, +}); + +storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx new file mode 100644 index 0000000000000..6749b41e81fc7 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { cleanup, fireEvent, render, wait } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { mockDynamicActionManager } from './test_data'; +import { TEST_SUBJ_DRILLDOWN_ITEM } from '../list_manage_drilldowns'; +import { WELCOME_MESSAGE_TEST_SUBJ } from '../drilldown_hello_bar'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { NotificationsStart } from 'kibana/public'; +import { toastDrilldownsCRUDError } from './i18n'; + +const storage = new Storage(new StubBrowserStorage()); +const notifications = coreMock.createStart().notifications; +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage, + notifications, +}); + +// https://github.com/elastic/kibana/issues/59469 +afterEach(cleanup); + +beforeEach(() => { + storage.clear(); + (notifications.toasts as jest.Mocked).addSuccess.mockClear(); + (notifications.toasts as jest.Mocked).addError.mockClear(); +}); + +test('Allows to manage drilldowns', async () => { + const screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + // no drilldowns in the list + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0); + + fireEvent.click(screen.getByText(/Create new/i)); + + let [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + expect(createHeading).toBeVisible(); + expect(screen.getByLabelText(/Back/i)).toBeVisible(); + + expect(createButton).toBeDisabled(); + + // input drilldown name + const name = 'Test name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: name }, + }); + + // select URL one + fireEvent.click(screen.getByText(/Go to URL/i)); + + // Input url + const URL = 'https://elastic.co'; + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: URL }, + }); + + [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + + expect(createButton).toBeEnabled(); + fireEvent.click(createButton); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(1)); + expect(screen.getByText(name)).toBeVisible(); + const editButton = screen.getByText(/edit/i); + fireEvent.click(editButton); + + expect(screen.getByText(/Edit Drilldown/i)).toBeVisible(); + // check that wizard is prefilled with current drilldown values + expect(screen.getByLabelText(/name/i)).toHaveValue(name); + expect(screen.getByLabelText(/url/i)).toHaveValue(URL); + + // input new drilldown name + const newName = 'New drilldown name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: newName }, + }); + fireEvent.click(screen.getByText(/save/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => screen.getByText(newName)); + + // delete drilldown from edit view + fireEvent.click(screen.getByText(/edit/i)); + fireEvent.click(screen.getByText(/delete/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Can delete multiple drilldowns', async () => { + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + const createDrilldown = async () => { + const oldCount = screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM).length; + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(oldCount + 1) + ); + }; + + await createDrilldown(); + await createDrilldown(); + await createDrilldown(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + checkboxes.forEach(checkbox => fireEvent.click(checkbox)); + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByText(/Delete \(3\)/i)); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Create only mode', async () => { + const onClose = jest.fn(); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + + await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); + expect(onClose).toBeCalled(); + expect(await mockDynamicActionManager.state.get().events.length).toBe(1); +}); + +test.todo("Error when can't fetch drilldown list"); + +test("Error when can't save drilldown changes", async () => { + const error = new Error('Oops'); + jest.spyOn(mockDynamicActionManager, 'createEvent').mockImplementationOnce(async () => { + throw error; + }); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(notifications.toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) + ); +}); + +test('Should show drilldown welcome message. Should be able to dismiss it', async () => { + let screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + expect(screen.getByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeVisible(); + fireEvent.click(screen.getByText(/hide/i)); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); + cleanup(); + + screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); +}); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx new file mode 100644 index 0000000000000..f22ccc2f26f02 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -0,0 +1,332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + AdvancedUiActionsStart, +} from '../../../../advanced_ui_actions/public'; +import { NotificationsStart } from '../../../../../../src/core/public'; +import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; +import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; +import { IStorageWrapper } from '../../../../../../src/plugins/kibana_utils/public'; +import { + DynamicActionManager, + UiActionsSerializedEvent, + UiActionsSerializedAction, + VALUE_CLICK_TRIGGER, + SELECT_RANGE_TRIGGER, + TriggerContextMapping, +} from '../../../../../../src/plugins/ui_actions/public'; +import { useContainerState } from '../../../../../../src/plugins/kibana_utils/common'; +import { DrilldownListItem } from '../list_manage_drilldowns'; +import { + toastDrilldownCreated, + toastDrilldownDeleted, + toastDrilldownEdited, + toastDrilldownsCRUDError, + toastDrilldownsDeleted, +} from './i18n'; +import { DrilldownFactoryContext } from '../../types'; + +interface ConnectedFlyoutManageDrilldownsProps { + placeContext: Context; + dynamicActionManager: DynamicActionManager; + viewMode?: 'create' | 'manage'; + onClose?: () => void; +} + +/** + * Represent current state (route) of FlyoutManageDrilldowns + */ +enum Routes { + Manage = 'manage', + Create = 'create', + Edit = 'edit', +} + +export function createFlyoutManageDrilldowns({ + advancedUiActions, + storage, + notifications, +}: { + advancedUiActions: AdvancedUiActionsStart; + storage: IStorageWrapper; + notifications: NotificationsStart; +}) { + // fine to assume this is static, + // because all action factories should be registered in setup phase + const allActionFactories = advancedUiActions.getActionFactories(); + const allActionFactoriesById = allActionFactories.reduce((acc, next) => { + acc[next.id] = next; + return acc; + }, {} as Record); + + return (props: ConnectedFlyoutManageDrilldownsProps) => { + const isCreateOnly = props.viewMode === 'create'; + + const selectedTriggers: Array = React.useMemo( + () => [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER], + [] + ); + + const factoryContext: DrilldownFactoryContext = React.useMemo( + () => ({ + placeContext: props.placeContext, + triggers: selectedTriggers, + }), + [props.placeContext, selectedTriggers] + ); + + const actionFactories = useCompatibleActionFactoriesForCurrentContext( + allActionFactories, + factoryContext + ); + + const [route, setRoute] = useState( + () => (isCreateOnly ? Routes.Create : Routes.Manage) // initial state is different depending on `viewMode` + ); + const [currentEditId, setCurrentEditId] = useState(null); + + const [shouldShowWelcomeMessage, onHideWelcomeMessage] = useWelcomeMessage(storage); + + const { + drilldowns, + createDrilldown, + editDrilldown, + deleteDrilldown, + } = useDrilldownsStateManager(props.dynamicActionManager, notifications); + + /** + * isCompatible promise is not yet resolved. + * Skip rendering until it is resolved + */ + if (!actionFactories) return null; + /** + * Drilldowns are not fetched yet or error happened during fetching + * In case of error user is notified with toast + */ + if (!drilldowns) return null; + + /** + * Needed for edit mode to prefill wizard fields with data from current edited drilldown + */ + function resolveInitialDrilldownWizardConfig(): DrilldownWizardConfig | undefined { + if (route !== Routes.Edit) return undefined; + if (!currentEditId) return undefined; + const drilldownToEdit = drilldowns?.find(d => d.eventId === currentEditId); + if (!drilldownToEdit) return undefined; + + return { + actionFactory: allActionFactoriesById[drilldownToEdit.action.factoryId], + actionConfig: drilldownToEdit.action.config as object, // TODO: config is unknown, but we know it always extends object + name: drilldownToEdit.action.name, + }; + } + + /** + * Maps drilldown to list item view model + */ + function mapToDrilldownToDrilldownListItem( + drilldown: UiActionsSerializedEvent + ): DrilldownListItem { + const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; + return { + id: drilldown.eventId, + drilldownName: drilldown.action.name, + actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId, + icon: actionFactory?.getIconType(factoryContext), + }; + } + + switch (route) { + case Routes.Create: + case Routes.Edit: + return ( + setRoute(Routes.Manage)} + onSubmit={({ actionConfig, actionFactory, name }) => { + if (route === Routes.Create) { + createDrilldown( + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } else { + editDrilldown( + currentEditId!, + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } + + if (isCreateOnly) { + if (props.onClose) { + props.onClose(); + } + } else { + setRoute(Routes.Manage); + } + + setCurrentEditId(null); + }} + onDelete={() => { + deleteDrilldown(currentEditId!); + setRoute(Routes.Manage); + setCurrentEditId(null); + }} + actionFactoryContext={factoryContext} + initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} + /> + ); + + case Routes.Manage: + default: + return ( + { + setCurrentEditId(null); + deleteDrilldown(ids); + }} + onEdit={id => { + setCurrentEditId(id); + setRoute(Routes.Edit); + }} + onCreate={() => { + setCurrentEditId(null); + setRoute(Routes.Create); + }} + onClose={props.onClose} + /> + ); + } + }; +} + +function useCompatibleActionFactoriesForCurrentContext( + actionFactories: Array>, + context: Context +) { + const [compatibleActionFactories, setCompatibleActionFactories] = useState< + Array> + >(); + useEffect(() => { + let canceled = false; + async function updateCompatibleFactoriesForContext() { + const compatibility = await Promise.all( + actionFactories.map(factory => factory.isCompatible(context)) + ); + if (canceled) return; + setCompatibleActionFactories(actionFactories.filter((_, i) => compatibility[i])); + } + updateCompatibleFactoriesForContext(); + return () => { + canceled = true; + }; + }, [context, actionFactories]); + + return compatibleActionFactories; +} + +function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { + const key = `drilldowns:hidWelcomeMessage`; + const [hidWelcomeMessage, setHidWelcomeMessage] = useState(storage.get(key) ?? false); + + return [ + !hidWelcomeMessage, + () => { + if (hidWelcomeMessage) return; + setHidWelcomeMessage(true); + storage.set(key, true); + }, + ]; +} + +function useDrilldownsStateManager( + actionManager: DynamicActionManager, + notifications: NotificationsStart +) { + const { events: drilldowns } = useContainerState(actionManager.state); + const [isLoading, setIsLoading] = useState(false); + const isMounted = useMountedState(); + + async function run(op: () => Promise) { + setIsLoading(true); + try { + await op(); + } catch (e) { + notifications.toasts.addError(e, { + title: toastDrilldownsCRUDError, + }); + if (!isMounted) return; + setIsLoading(false); + return; + } + } + + async function createDrilldown( + action: UiActionsSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.createEvent(action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownCreated.title, + text: toastDrilldownCreated.text(action.name), + }); + }); + } + + async function editDrilldown( + drilldownId: string, + action: UiActionsSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.updateEvent(drilldownId, action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownEdited.title, + text: toastDrilldownEdited.text(action.name), + }); + }); + } + + async function deleteDrilldown(drilldownIds: string | string[]) { + await run(async () => { + drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; + await actionManager.deleteEvents(drilldownIds); + notifications.toasts.addSuccess( + drilldownIds.length === 1 + ? { + title: toastDrilldownDeleted.title, + text: toastDrilldownDeleted.text, + } + : { + title: toastDrilldownsDeleted.title, + text: toastDrilldownsDeleted.text(drilldownIds.length), + } + ); + }); + } + + return { drilldowns, isLoading, createDrilldown, editDrilldown, deleteDrilldown }; +} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..70f4d735e2a74 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const toastDrilldownCreated = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', + { + defaultMessage: 'Drilldown created', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', { + defaultMessage: 'You created "{drilldownName}"', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownEdited = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', + { + defaultMessage: 'Drilldown edited', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', { + defaultMessage: 'You edited "{drilldownName}"', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', + { + defaultMessage: 'Drilldown deleted', + } + ), + text: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', + { + defaultMessage: 'You deleted a drilldown', + } + ), +}; + +export const toastDrilldownsDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', + { + defaultMessage: 'Drilldowns deleted', + } + ), + text: (n: number) => + i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', + { + defaultMessage: 'You deleted {n} drilldowns', + values: { + n, + }, + } + ), +}; + +export const toastDrilldownsCRUDError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', + { + defaultMessage: 'Error saving drilldown', + description: 'Title for generic error toast when persisting drilldown updates failed', + } +); + +export const toastDrilldownsFetchError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsFetchErrorTitle', + { + defaultMessage: 'Error fetching drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts new file mode 100644 index 0000000000000..f084a3e563c23 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './connected_flyout_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts new file mode 100644 index 0000000000000..b8deaa8b842bc --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { + DynamicActionManager, + DynamicActionManagerState, + UiActionsSerializedAction, + TriggerContextMapping, +} from '../../../../../../src/plugins/ui_actions/public'; +import { createStateContainer } from '../../../../../../src/plugins/kibana_utils/common'; + +class MockDynamicActionManager implements PublicMethodsOf { + public readonly state = createStateContainer({ + isFetchingEvents: false, + fetchCount: 0, + events: [], + }); + + async count() { + return this.state.get().events.length; + } + + async list() { + return this.state.get().events; + } + + async createEvent( + action: UiActionsSerializedAction, + triggers: Array + ) { + const event = { + action, + triggers, + eventId: uuid(), + }; + const state = this.state.get(); + this.state.set({ + ...state, + events: [...state.events, event], + }); + } + + async deleteEvents(eventIds: string[]) { + const state = this.state.get(); + let events = state.events; + + eventIds.forEach(id => { + events = events.filter(e => e.eventId !== id); + }); + + this.state.set({ + ...state, + events, + }); + } + + async updateEvent( + eventId: string, + action: UiActionsSerializedAction, + triggers: Array + ) { + const state = this.state.get(); + const events = state.events; + const idx = events.findIndex(e => e.eventId === eventId); + const event = { + eventId, + action, + triggers, + }; + + this.state.set({ + ...state, + events: [...events.slice(0, idx), event, ...events.slice(idx + 1)], + }); + } + + async deleteEvent() { + throw new Error('not implemented'); + } + + async start() {} + async stop() {} +} + +export const mockDynamicActionManager = (new MockDynamicActionManager() as unknown) as DynamicActionManager; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx index 7a9e19342f27c..c4a4630397f1c 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx @@ -8,6 +8,16 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { DrilldownHelloBar } from '.'; -storiesOf('components/DrilldownHelloBar', module).add('default', () => { - return ; -}); +const Demo = () => { + const [show, setShow] = React.useState(true); + return show ? ( + { + setShow(false); + }} + /> + ) : null; +}; + +storiesOf('components/DrilldownHelloBar', module).add('default', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx index 1ef714f7b86e2..8c6739a8ad6c8 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx @@ -5,22 +5,58 @@ */ import React from 'react'; +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, + EuiText, + EuiLink, + EuiSpacer, + EuiButtonEmpty, + EuiIcon, +} from '@elastic/eui'; +import { txtHideHelpButtonLabel, txtHelpText, txtViewDocsLinkLabel } from './i18n'; export interface DrilldownHelloBarProps { docsLink?: string; + onHideClick?: () => void; } -/** - * @todo https://github.com/elastic/kibana/issues/55311 - */ -export const DrilldownHelloBar: React.FC = ({ docsLink }) => { +export const WELCOME_MESSAGE_TEST_SUBJ = 'drilldowns-welcome-message-test-subj'; + +export const DrilldownHelloBar: React.FC = ({ + docsLink, + onHideClick = () => {}, +}) => { return ( -
+ + +
+ +
+
+ + + {txtHelpText} + + {docsLink && ( + <> + + {txtViewDocsLinkLabel} + + )} + + + + {txtHideHelpButtonLabel} + + + + } + /> ); }; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts new file mode 100644 index 0000000000000..63dc95dabc0fb --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtHelpText = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.helpText', + { + defaultMessage: + 'Drilldowns provide the ability to define a new behavior when interacting with a panel. You can add multiple options or simply override the default filtering behavior.', + } +); + +export const txtViewDocsLinkLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', + { + defaultMessage: 'View docs', + } +); + +export const txtHideHelpButtonLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', + { + defaultMessage: 'Hide', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx deleted file mode 100644 index 3748fc666c81c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; - -// eslint-disable-next-line -export interface DrilldownPickerProps {} - -export const DrilldownPicker: React.FC = () => { - return ( - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx deleted file mode 100644 index 4f024b7d9cd6a..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FlyoutCreateDrilldown } from '.'; - -storiesOf('components/FlyoutCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx deleted file mode 100644 index b45ac9197c7e0..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiButton } from '@elastic/eui'; -import { FormCreateDrilldown } from '../form_create_drilldown'; -import { FlyoutFrame } from '../flyout_frame'; -import { txtCreateDrilldown } from './i18n'; -import { FlyoutCreateDrilldownActionContext } from '../../actions'; - -export interface FlyoutCreateDrilldownProps { - context: FlyoutCreateDrilldownActionContext; - onClose?: () => void; -} - -export const FlyoutCreateDrilldown: React.FC = ({ - context, - onClose, -}) => { - const footer = ( - {}} fill> - {txtCreateDrilldown} - - ); - - return ( - - - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx new file mode 100644 index 0000000000000..152cd393b9d3e --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutDrilldownWizard } from '.'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; + +storiesOf('components/FlyoutDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('open in flyout - create', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + /> + + ); + }) + .add('open in flyout - edit', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }) + .add('open in flyout - edit, just 1 action type', () => { + return ( + {}}> + {}} + drilldownActionFactories={[dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx new file mode 100644 index 0000000000000..faa965a98a4bb --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormDrilldownWizard } from '../form_drilldown_wizard'; +import { FlyoutFrame } from '../flyout_frame'; +import { + txtCreateDrilldownButtonLabel, + txtCreateDrilldownTitle, + txtDeleteDrilldownButtonLabel, + txtEditDrilldownButtonLabel, + txtEditDrilldownTitle, +} from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; +import { AdvancedUiActionsActionFactory as ActionFactory } from '../../../../advanced_ui_actions/public'; + +export interface DrilldownWizardConfig { + name: string; + actionFactory?: ActionFactory; + actionConfig?: ActionConfig; +} + +export interface FlyoutDrilldownWizardProps { + drilldownActionFactories: Array>; + + onSubmit?: (drilldownWizardConfig: Required) => void; + onDelete?: () => void; + onClose?: () => void; + onBack?: () => void; + + mode?: 'create' | 'edit'; + initialDrilldownWizardConfig?: DrilldownWizardConfig; + + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; + + actionFactoryContext?: object; +} + +export function FlyoutDrilldownWizard({ + onClose, + onBack, + onSubmit = () => {}, + initialDrilldownWizardConfig, + mode = 'create', + onDelete = () => {}, + showWelcomeMessage = true, + onWelcomeHideClick, + drilldownActionFactories, + actionFactoryContext, +}: FlyoutDrilldownWizardProps) { + const [wizardConfig, setWizardConfig] = useState( + () => + initialDrilldownWizardConfig ?? { + name: '', + } + ); + + const isActionValid = ( + config: DrilldownWizardConfig + ): config is Required => { + if (!wizardConfig.name) return false; + if (!wizardConfig.actionFactory) return false; + if (!wizardConfig.actionConfig) return false; + + return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig); + }; + + const footer = ( + { + if (isActionValid(wizardConfig)) { + onSubmit(wizardConfig); + } + }} + fill + isDisabled={!isActionValid(wizardConfig)} + > + {mode === 'edit' ? txtEditDrilldownButtonLabel : txtCreateDrilldownButtonLabel} + + ); + + return ( + } + > + { + setWizardConfig({ + ...wizardConfig, + name: newName, + }); + }} + actionConfig={wizardConfig.actionConfig} + onActionConfigChange={newActionConfig => { + setWizardConfig({ + ...wizardConfig, + actionConfig: newActionConfig, + }); + }} + currentActionFactory={wizardConfig.actionFactory} + onActionFactoryChange={actionFactory => { + if (!actionFactory) { + setWizardConfig({ + ...wizardConfig, + actionFactory: undefined, + actionConfig: undefined, + }); + } else { + setWizardConfig({ + ...wizardConfig, + actionFactory, + actionConfig: actionFactory.createConfig(), + }); + } + }} + actionFactories={drilldownActionFactories} + actionFactoryContext={actionFactoryContext!} + /> + {mode === 'edit' && ( + <> + + + {txtDeleteDrilldownButtonLabel} + + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts new file mode 100644 index 0000000000000..a4a2754a444ab --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtCreateDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', + { + defaultMessage: 'Create Drilldown', + } +); + +export const txtEditDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', + { + defaultMessage: 'Edit Drilldown', + } +); + +export const txtCreateDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel', + { + defaultMessage: 'Create drilldown', + } +); + +export const txtEditDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel', + { + defaultMessage: 'Save', + } +); + +export const txtDeleteDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', + { + defaultMessage: 'Delete drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts new file mode 100644 index 0000000000000..96ed23bf112c9 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './flyout_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx index 2715637f6392f..cb223db556f56 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx @@ -21,6 +21,13 @@ storiesOf('components/FlyoutFrame', module) .add('with onClose', () => { return console.log('onClose')}>test; }) + .add('with onBack', () => { + return ( + console.log('onClose')} title={'Title'}> + test + + ); + }) .add('custom footer', () => { return click me!}>test; }) diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx index b5fb52fcf5c18..0a3989487745f 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx @@ -6,9 +6,11 @@ import React from 'react'; import { render } from 'react-dom'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { FlyoutFrame } from '.'; +afterEach(cleanup); + describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx index 2945cfd739482..b55cbd88d0dc0 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx @@ -13,13 +13,16 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, + EuiButtonIcon, } from '@elastic/eui'; -import { txtClose } from './i18n'; +import { txtClose, txtBack } from './i18n'; export interface FlyoutFrameProps { title?: React.ReactNode; footer?: React.ReactNode; + banner?: React.ReactNode; onClose?: () => void; + onBack?: () => void; } /** @@ -30,11 +33,31 @@ export const FlyoutFrame: React.FC = ({ footer, onClose, children, + onBack, + banner, }) => { - const headerFragment = title && ( + const headerFragment = (title || onBack) && ( -

{title}

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

{title}

+
+ )} +
); @@ -64,7 +87,7 @@ export const FlyoutFrame: React.FC = ({ return ( <> {headerFragment} - {children} + {children} {footerFragment} ); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts index 257d7d36dbee1..23af89ebf9bc7 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts @@ -6,6 +6,10 @@ import { i18n } from '@kbn/i18n'; -export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.Close', { +export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel', { defaultMessage: 'Close', }); + +export const txtBack = i18n.translate('xpack.drilldowns.components.FlyoutFrame.BackButtonLabel', { + defaultMessage: 'Back', +}); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..0529f0451b16a --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutListManageDrilldowns } from './flyout_list_manage_drilldowns'; + +storiesOf('components/FlyoutListManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx new file mode 100644 index 0000000000000..a44a7ccccb4dc --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FlyoutFrame } from '../flyout_frame'; +import { DrilldownListItem, ListManageDrilldowns } from '../list_manage_drilldowns'; +import { txtManageDrilldowns } from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; + +export interface FlyoutListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + onClose?: () => void; + onCreate?: () => void; + onEdit?: (drilldownId: string) => void; + onDelete?: (drilldownIds: string[]) => void; + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; +} + +export function FlyoutListManageDrilldowns({ + drilldowns, + onClose = () => {}, + onCreate, + onDelete, + onEdit, + showWelcomeMessage = true, + onWelcomeHideClick, +}: FlyoutListManageDrilldownsProps) { + return ( + } + > + + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..0dd4e37d4dddd --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtManageDrilldowns = i18n.translate( + 'xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle', + { + defaultMessage: 'Manage Drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts new file mode 100644 index 0000000000000..f8c9d224fb292 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './flyout_list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx deleted file mode 100644 index e7e1d67473e8c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FormCreateDrilldown } from '.'; - -const DemoEditName: React.FC = () => { - const [name, setName] = React.useState(''); - - return ; -}; - -storiesOf('components/FormCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('[name=foobar]', () => { - return ; - }) - .add('can edit name', () => ) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx deleted file mode 100644 index 4422de604092b..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiForm, EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { DrilldownHelloBar } from '../drilldown_hello_bar'; -import { txtNameOfDrilldown, txtUntitledDrilldown, txtDrilldownAction } from './i18n'; -import { DrilldownPicker } from '../drilldown_picker'; - -const noop = () => {}; - -export interface FormCreateDrilldownProps { - name?: string; - onNameChange?: (name: string) => void; -} - -export const FormCreateDrilldown: React.FC = ({ - name = '', - onNameChange = noop, -}) => { - const nameFragment = ( - - onNameChange(event.target.value)} - data-test-subj="dynamicActionNameInput" - /> - - ); - - const triggerPicker =
Trigger Picker will be here
; - const actionPicker = ( - - - - ); - - return ( - <> - - {nameFragment} - {triggerPicker} - {actionPicker} - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx new file mode 100644 index 0000000000000..2fc35eb6b5298 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { FormDrilldownWizard } from '.'; + +const DemoEditName: React.FC = () => { + const [name, setName] = React.useState(''); + + return ( + <> + {' '} +
name: {name}
+ + ); +}; + +storiesOf('components/FormDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('[name=foobar]', () => { + return ; + }) + .add('can edit name', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx similarity index 70% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx index 6691966e47e64..4560773cc8a6d 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx @@ -6,21 +6,23 @@ import React from 'react'; import { render } from 'react-dom'; -import { FormCreateDrilldown } from '.'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { FormDrilldownWizard } from './form_drilldown_wizard'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { txtNameOfDrilldown } from './i18n'; -describe('', () => { +afterEach(cleanup); + +describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); - render( {}} />, div); + render( {}} actionFactoryContext={{}} />, div); }); describe('[name=]', () => { test('if name not provided, uses to empty string', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -29,10 +31,10 @@ describe('', () => { expect(input?.value).toBe(''); }); - test('can set name input field value', () => { + test('can set initial name input field value', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -40,7 +42,7 @@ describe('', () => { expect(input?.value).toBe('foo'); - render(, div); + render(, div); expect(input?.value).toBe('bar'); }); @@ -48,7 +50,7 @@ describe('', () => { test('fires onNameChange callback on name change', () => { const onNameChange = jest.fn(); const utils = renderTestingLibrary( - + ); const input = utils.getByLabelText(txtNameOfDrilldown); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx new file mode 100644 index 0000000000000..bdafaaf07873c --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; +import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + ActionWizard, +} from '../../../../advanced_ui_actions/public'; + +const noopFn = () => {}; + +export interface FormDrilldownWizardProps { + name?: string; + onNameChange?: (name: string) => void; + + currentActionFactory?: ActionFactory; + onActionFactoryChange?: (actionFactory: ActionFactory | null) => void; + actionFactoryContext: object; + + actionConfig?: object; + onActionConfigChange?: (config: object) => void; + + actionFactories?: ActionFactory[]; +} + +export const FormDrilldownWizard: React.FC = ({ + name = '', + actionConfig, + currentActionFactory, + onNameChange = noopFn, + onActionConfigChange = noopFn, + onActionFactoryChange = noopFn, + actionFactories = [], + actionFactoryContext, +}) => { + const nameFragment = ( + + onNameChange(event.target.value)} + data-test-subj="dynamicActionNameInput" + /> + + ); + + const actionWizard = ( + 1 ? txtDrilldownAction : undefined} + fullWidth={true} + > + onActionFactoryChange(actionFactory)} + onConfigChange={config => onActionConfigChange(config)} + context={actionFactoryContext} + /> + + ); + + return ( + <> + + {nameFragment} + + {actionWizard} + + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts similarity index 89% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts index 4c0e287935edd..e9b19ab0afa97 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const txtNameOfDrilldown = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', { - defaultMessage: 'Name of drilldown', + defaultMessage: 'Name', } ); @@ -23,6 +23,6 @@ export const txtUntitledDrilldown = i18n.translate( export const txtDrilldownAction = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', { - defaultMessage: 'Drilldown action', + defaultMessage: 'Action', } ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx similarity index 85% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx index c2c5a7e435b39..4aea824de00d7 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './form_create_drilldown'; +export * from './form_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..fbc7c9dcfb4a1 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtCreateDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel', + { + defaultMessage: 'Create new', + } +); + +export const txtEditDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel', + { + defaultMessage: 'Edit', + } +); + +export const txtDeleteDrilldowns = (count: number) => + i18n.translate('xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel', { + defaultMessage: 'Delete ({count})', + values: { + count, + }, + }); + +export const txtSelectDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel', + { + defaultMessage: 'Select this drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx new file mode 100644 index 0000000000000..82b6ce27af6d4 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..eafe50bab2016 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { ListManageDrilldowns } from './list_manage_drilldowns'; + +storiesOf('components/ListManageDrilldowns', module).add('default', () => ( + +)); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx new file mode 100644 index 0000000000000..4a4d67b08b1d3 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { cleanup, fireEvent, render } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global +import { + DrilldownListItem, + ListManageDrilldowns, + TEST_SUBJ_DRILLDOWN_ITEM, +} from './list_manage_drilldowns'; + +// TODO: for some reason global cleanup from RTL doesn't work +// afterEach is not available for it globally during setup +afterEach(cleanup); + +const drilldowns: DrilldownListItem[] = [ + { id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' }, + { id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' }, + { id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' }, +]; + +test('Render list of drilldowns', () => { + const screen = render(); + expect(screen.getAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(drilldowns.length); +}); + +test('Emit onEdit() when clicking on edit drilldown', () => { + const fn = jest.fn(); + const screen = render(); + + const editButtons = screen.getAllByText('Edit'); + expect(editButtons).toHaveLength(drilldowns.length); + fireEvent.click(editButtons[1]); + expect(fn).toBeCalledWith(drilldowns[1].id); +}); + +test('Emit onCreate() when clicking on create drilldown', () => { + const fn = jest.fn(); + const screen = render(); + fireEvent.click(screen.getByText('Create new')); + expect(fn).toBeCalled(); +}); + +test('Delete button is not visible when non is selected', () => { + const fn = jest.fn(); + const screen = render(); + expect(screen.queryByText(/Delete/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Create/i)).toBeInTheDocument(); +}); + +test('Can delete drilldowns', () => { + const fn = jest.fn(); + const screen = render(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + + fireEvent.click(checkboxes[1]); + fireEvent.click(checkboxes[2]); + + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText(/Delete \(2\)/i)); + + expect(fn).toBeCalledWith([drilldowns[1].id, drilldowns[2].id]); +}); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx new file mode 100644 index 0000000000000..5a15781a1faf2 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiTextColor, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { + txtCreateDrilldown, + txtDeleteDrilldowns, + txtEditDrilldown, + txtSelectDrilldown, +} from './i18n'; + +export interface DrilldownListItem { + id: string; + actionName: string; + drilldownName: string; + icon?: string; +} + +export interface ListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + + onEdit?: (id: string) => void; + onCreate?: () => void; + onDelete?: (ids: string[]) => void; +} + +const noop = () => {}; + +export const TEST_SUBJ_DRILLDOWN_ITEM = 'list-manage-drilldowns-item'; + +export function ListManageDrilldowns({ + drilldowns, + onEdit = noop, + onCreate = noop, + onDelete = noop, +}: ListManageDrilldownsProps) { + const [selectedDrilldowns, setSelectedDrilldowns] = useState([]); + + const columns: Array> = [ + { + field: 'drilldownName', + name: 'Name', + truncateText: true, + width: '50%', + }, + { + name: 'Action', + render: (drilldown: DrilldownListItem) => ( + + {drilldown.icon && ( + + + + )} + + {drilldown.actionName} + + + ), + }, + { + align: 'right', + render: (drilldown: DrilldownListItem) => ( + onEdit(drilldown.id)}> + {txtEditDrilldown} + + ), + }, + ]; + + return ( + <> + { + setSelectedDrilldowns(selection.map(drilldown => drilldown.id)); + }, + selectableMessage: () => txtSelectDrilldown, + }} + rowProps={{ + 'data-test-subj': TEST_SUBJ_DRILLDOWN_ITEM, + }} + hasActions={true} + /> + + {selectedDrilldowns.length === 0 ? ( + onCreate()}> + {txtCreateDrilldown} + + ) : ( + onDelete(selectedDrilldowns)}> + {txtDeleteDrilldowns(selectedDrilldowns.length)} + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/index.ts b/x-pack/plugins/drilldowns/public/index.ts index 63e7a12235462..044e29c671de4 100644 --- a/x-pack/plugins/drilldowns/public/index.ts +++ b/x-pack/plugins/drilldowns/public/index.ts @@ -7,12 +7,14 @@ import { DrilldownsPlugin } from './plugin'; export { - DrilldownsSetupContract, - DrilldownsSetupDependencies, - DrilldownsStartContract, - DrilldownsStartDependencies, + SetupContract as DrilldownsSetup, + SetupDependencies as DrilldownsSetupDependencies, + StartContract as DrilldownsStart, + StartDependencies as DrilldownsStartDependencies, } from './plugin'; export function plugin() { return new DrilldownsPlugin(); } + +export { DrilldownDefinition } from './types'; diff --git a/x-pack/plugins/drilldowns/public/mocks.ts b/x-pack/plugins/drilldowns/public/mocks.ts index bfade1674072a..18816243a3572 100644 --- a/x-pack/plugins/drilldowns/public/mocks.ts +++ b/x-pack/plugins/drilldowns/public/mocks.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DrilldownsSetupContract, DrilldownsStartContract } from '.'; +import { DrilldownsSetup, DrilldownsStart } from '.'; -export type Setup = jest.Mocked; -export type Start = jest.Mocked; +export type Setup = jest.Mocked; +export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { @@ -17,12 +17,14 @@ const createSetupContract = (): Setup => { }; const createStartContract = (): Start => { - const startContract: Start = {}; + const startContract: Start = { + FlyoutManageDrilldowns: jest.fn(), + }; return startContract; }; -export const bfetchPluginMock = { +export const drilldownsPluginMock = { createSetupContract, createStartContract, }; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts index b89172541b91e..bbc06847d5842 100644 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ b/x-pack/plugins/drilldowns/public/plugin.ts @@ -6,52 +6,46 @@ import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { DrilldownService } from './service'; -import { - FlyoutCreateDrilldownActionContext, - FlyoutEditDrilldownActionContext, - OPEN_FLYOUT_ADD_DRILLDOWN, - OPEN_FLYOUT_EDIT_DRILLDOWN, -} from './actions'; - -export interface DrilldownsSetupDependencies { +import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../advanced_ui_actions/public'; +import { DrilldownService, DrilldownServiceSetupContract } from './services'; +import { createFlyoutManageDrilldowns } from './components/connected_flyout_manage_drilldowns'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; + +export interface SetupDependencies { uiActions: UiActionsSetup; + advancedUiActions: AdvancedUiActionsSetup; } -export interface DrilldownsStartDependencies { +export interface StartDependencies { uiActions: UiActionsStart; + advancedUiActions: AdvancedUiActionsStart; } -export type DrilldownsSetupContract = Pick; +export type SetupContract = DrilldownServiceSetupContract; // eslint-disable-next-line -export interface DrilldownsStartContract {} - -declare module '../../../../src/plugins/ui_actions/public' { - export interface ActionContextMapping { - [OPEN_FLYOUT_ADD_DRILLDOWN]: FlyoutCreateDrilldownActionContext; - [OPEN_FLYOUT_EDIT_DRILLDOWN]: FlyoutEditDrilldownActionContext; - } +export interface StartContract { + FlyoutManageDrilldowns: ReturnType; } export class DrilldownsPlugin - implements - Plugin< - DrilldownsSetupContract, - DrilldownsStartContract, - DrilldownsSetupDependencies, - DrilldownsStartDependencies - > { + implements Plugin { private readonly service = new DrilldownService(); - public setup(core: CoreSetup, plugins: DrilldownsSetupDependencies): DrilldownsSetupContract { - this.service.bootstrap(core, plugins); + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + const setup = this.service.setup(core, plugins); - return this.service; + return setup; } - public start(core: CoreStart, plugins: DrilldownsStartDependencies): DrilldownsStartContract { - return {}; + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return { + FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ + advancedUiActions: plugins.advancedUiActions, + storage: new Storage(localStorage), + notifications: core.notifications, + }), + }; } public stop() {} diff --git a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts deleted file mode 100644 index 7745c30b4e335..0000000000000 --- a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CoreSetup } from 'src/core/public'; -// import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldownAction, FlyoutEditDrilldownAction } from '../actions'; -import { DrilldownsSetupDependencies } from '../plugin'; - -export class DrilldownService { - bootstrap(core: CoreSetup, { uiActions }: DrilldownsSetupDependencies) { - const overlays = async () => (await core.getStartServices())[0].overlays; - - const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutCreateDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); - - const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutEditDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); - } - - /** - * Convenience method to register a drilldown. (It should set-up all the - * necessary triggers and actions.) - */ - registerDrilldown = (): void => { - throw new Error('not implemented'); - }; -} diff --git a/x-pack/plugins/drilldowns/public/services/drilldown_service.ts b/x-pack/plugins/drilldowns/public/services/drilldown_service.ts new file mode 100644 index 0000000000000..bfbe514d46095 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/services/drilldown_service.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { AdvancedUiActionsSetup } from '../../../advanced_ui_actions/public'; +import { DrilldownDefinition, DrilldownFactoryContext } from '../types'; +import { UiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../../../../src/plugins/ui_actions/public'; + +export interface DrilldownServiceSetupDeps { + advancedUiActions: AdvancedUiActionsSetup; +} + +export interface DrilldownServiceSetupContract { + /** + * Convenience method to register a drilldown. + */ + registerDrilldown: < + Config extends object = object, + CreationContext extends object = object, + ExecutionContext extends object = object + >( + drilldown: DrilldownDefinition + ) => void; +} + +export class DrilldownService { + setup( + core: CoreSetup, + { advancedUiActions }: DrilldownServiceSetupDeps + ): DrilldownServiceSetupContract { + const registerDrilldown = < + Config extends object = object, + CreationContext extends object = object, + ExecutionContext extends object = object + >({ + id: factoryId, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + euiIcon, + execute, + }: DrilldownDefinition) => { + const actionFactory: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + > = { + id: factoryId, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + getIconType: () => euiIcon, + isCompatible: async () => true, + create: serializedAction => ({ + id: '', + type: factoryId, + getIconType: () => euiIcon, + getDisplayName: () => serializedAction.name, + execute: async context => await execute(serializedAction.config, context), + }), + } as ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >; + + advancedUiActions.registerActionFactory(actionFactory); + }; + + return { + registerDrilldown, + }; + } +} diff --git a/x-pack/plugins/drilldowns/public/service/index.ts b/x-pack/plugins/drilldowns/public/services/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/service/index.ts rename to x-pack/plugins/drilldowns/public/services/index.ts diff --git a/x-pack/plugins/drilldowns/public/types.ts b/x-pack/plugins/drilldowns/public/types.ts new file mode 100644 index 0000000000000..a8232887f9ca6 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/types.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AdvancedUiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../advanced_ui_actions/public'; + +/** + * This is a convenience interface to register a drilldown. Drilldown has + * ability to collect configuration from user. Once drilldown is executed it + * receives the collected information together with the context of the + * user's interaction. + * + * `Config` is a serializable object containing the configuration that the + * drilldown is able to collect using UI. + * + * `PlaceContext` is an object that the app that opens drilldown management + * flyout provides to the React component, specifying the contextual information + * about that app. For example, on Dashboard app this context contains + * information about the current embeddable and dashboard. + * + * `ExecutionContext` is an object created in response to user's interaction + * and provided to the `execute` function of the drilldown. This object contains + * information about the action user performed. + */ +export interface DrilldownDefinition< + Config extends object = object, + PlaceContext extends object = object, + ExecutionContext extends object = object +> { + /** + * Globally unique identifier for this drilldown. + */ + id: string; + + /** + * Function that returns default config for this drilldown. + */ + createConfig: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >['createConfig']; + + /** + * `UiComponent` that collections config for this drilldown. You can create + * a React component and transform it `UiComponent` using `uiToReactComponent` + * helper from `kibana_utils` plugin. + * + * ```tsx + * import React from 'react'; + * import { uiToReactComponent } from 'src/plugins/kibana_utils'; + * import { UiActionsCollectConfigProps as CollectConfigProps } from 'src/plugins/ui_actions/public'; + * + * type Props = CollectConfigProps; + * + * const ReactCollectConfig: React.FC = () => { + * return
Collecting config...'
; + * }; + * + * export const CollectConfig = uiToReactComponent(ReactCollectConfig); + * ``` + */ + CollectConfig: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >['CollectConfig']; + + /** + * A validator function for the config object. Should always return a boolean + * given any input. + */ + isConfigValid: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >['isConfigValid']; + + /** + * Name of EUI icon to display when showing this drilldown to user. + */ + euiIcon?: string; + + /** + * Should return an internationalized name of the drilldown, which will be + * displayed to the user. + */ + getDisplayName: () => string; + + /** + * Implements the "navigation" action of the drilldown. This happens when + * user clicks something in the UI that executes a trigger to which this + * drilldown was attached. + * + * @param config Config object that user configured this drilldown with. + * @param context Object that represents context in which the underlying + * `UIAction` of this drilldown is being executed in. + */ + execute(config: Config, context: ExecutionContext): void; +} + +/** + * Context object used when creating a drilldown. + */ +export interface DrilldownFactoryContext { + /** + * Context provided to the drilldown factory by the place where the UI is + * rendered. For example, for the "dashboard" place, this context contains + * the ID of the current dashboard, which could be used for filtering it out + * of the list. + */ + placeContext: T; + + /** + * List of triggers that user selected in the UI. + */ + triggers: string[]; +} diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index 08ba10ff69207..ac46d84469513 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -143,8 +143,7 @@ export class ReportingPublicPlugin implements Plugin { }, }); - uiActions.registerAction(action); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); share.register(csvReportingProvider({ apiClient, toasts, license$ })); share.register( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fe7ad863945c5..6290366e4b93f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -636,7 +636,6 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "一致するオブジェクトが見つかりませんでした。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} が追加されました", "embeddableApi.addPanel.Title": "パネルの追加", - "embeddableApi.customizePanel.action.displayName": "パネルをカスタマイズ", "embeddableApi.customizePanel.modal.cancel": "キャンセル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "パネルタイトル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "パネルのカスタムタイトルを入力してください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e1cfa5e4ef358..37527023d7208 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -636,7 +636,6 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "未找到任何匹配对象。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} 已添加", "embeddableApi.addPanel.Title": "添加面板", - "embeddableApi.customizePanel.action.displayName": "定制面板", "embeddableApi.customizePanel.modal.cancel": "取消", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "面板标题", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "为面板输入定制标题", From 66b5efd0841a3881ae73ca04882a6c17e43ad830 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 24 Mar 2020 08:46:11 +0100 Subject: [PATCH 16/64] [ML] Module setup with dynamic model memory estimation (#60656) * [ML] add estimateModelMemory to the setup endpoint * [ML] wip caching cardinality checks * [ML] refactor * [ML] fix a fallback time range * [ML] fix typing issue * [ML] fields_aggs_cache.ts as part of fields_service * [ML] fix types, add comments * [ML] check for MML overrides * [ML] disable estimateModelMemory * [ML] fix typing * [ML] check for empty max mml * [ML] refactor, update types, fix jobsForModelMemoryEstimation * [ML] fix override lookup * [ML] resolve nit comments * [ML] init jobsForModelMemoryEstimation --- x-pack/plugins/ml/common/types/modules.ts | 10 +- .../jobs/new_job/recognize/page.tsx | 1 + .../services/ml_api_service/index.ts | 3 + .../calculate_model_memory_limit.ts | 160 ++++++------- .../models/data_recognizer/data_recognizer.ts | 215 +++++++++++++----- .../fields_service/fields_aggs_cache.ts | 66 ++++++ .../models/fields_service/fields_service.ts | 128 +++++++---- x-pack/plugins/ml/server/routes/modules.ts | 40 ++-- .../ml/server/routes/schemas/modules.ts | 5 + .../shared_services/providers/modules.ts | 9 +- 10 files changed, 443 insertions(+), 194 deletions(-) create mode 100644 x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts diff --git a/x-pack/plugins/ml/common/types/modules.ts b/x-pack/plugins/ml/common/types/modules.ts index e61ff9972d601..b476762f6efca 100644 --- a/x-pack/plugins/ml/common/types/modules.ts +++ b/x-pack/plugins/ml/common/types/modules.ts @@ -90,8 +90,14 @@ export interface DataRecognizerConfigResponse { }; } -export type GeneralOverride = any; - export type JobOverride = Partial; +export type GeneralJobsOverride = Omit; +export type JobSpecificOverride = JobOverride & { job_id: Job['job_id'] }; + +export function isGeneralJobOverride(override: JobOverride): override is GeneralJobsOverride { + return override.job_id === undefined; +} + +export type GeneralDatafeedsOverride = Partial>; export type DatafeedOverride = Partial; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx index 50c35ec426acb..9b76b9be9bf45 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx @@ -172,6 +172,7 @@ export const Page: FC = ({ moduleId, existingGroupIds }) => { startDatafeed: startDatafeedAfterSave, ...(jobOverridesPayload !== null ? { jobOverrides: jobOverridesPayload } : {}), ...resultTimeRange, + estimateModelMemory: false, }); const { datafeeds: datafeedsResponse, jobs: jobsResponse, kibana: kibanaResponse } = response; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index cd4a97bd10ed4..df59678452e2f 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -367,6 +367,7 @@ export const ml = { start, end, jobOverrides, + estimateModelMemory, }: { moduleId: string; prefix?: string; @@ -378,6 +379,7 @@ export const ml = { start?: number; end?: number; jobOverrides?: Array>; + estimateModelMemory?: boolean; }) { const body = JSON.stringify({ prefix, @@ -389,6 +391,7 @@ export const ml = { start, end, jobOverrides, + estimateModelMemory, }); return http({ diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts index c97bbe07fffda..cd61dd9eddcdd 100644 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -6,6 +6,7 @@ import numeral from '@elastic/numeral'; import { APICaller } from 'kibana/server'; +import { MLCATEGORY } from '../../../common/constants/field_types'; import { AnalysisConfig } from '../../../common/types/anomaly_detection_jobs'; import { fieldsServiceProvider } from '../fields_service'; @@ -34,92 +35,96 @@ export interface ModelMemoryEstimate { /** * Retrieves overall and max bucket cardinalities. */ -async function getCardinalities( - callAsCurrentUser: APICaller, - analysisConfig: AnalysisConfig, - indexPattern: string, - query: any, - timeFieldName: string, - earliestMs: number, - latestMs: number -): Promise<{ - overallCardinality: { [key: string]: number }; - maxBucketCardinality: { [key: string]: number }; -}> { - /** - * Fields not involved in cardinality check - */ - const excludedKeywords = new Set( - /** - * The keyword which is used to mean the output of categorization, - * so it will have cardinality zero in the actual input data. - */ - 'mlcategory' - ); - +const cardinalityCheckProvider = (callAsCurrentUser: APICaller) => { const fieldsService = fieldsServiceProvider(callAsCurrentUser); - const { detectors, influencers, bucket_span: bucketSpan } = analysisConfig; - - let overallCardinality = {}; - let maxBucketCardinality = {}; - const overallCardinalityFields: Set = detectors.reduce( - ( - acc, - { - by_field_name: byFieldName, - partition_field_name: partitionFieldName, - over_field_name: overFieldName, - } - ) => { - [byFieldName, partitionFieldName, overFieldName] - .filter(field => field !== undefined && field !== '' && !excludedKeywords.has(field)) - .forEach(key => { - acc.add(key as string); - }); - return acc; - }, - new Set() - ); - - const maxBucketFieldCardinalities: string[] = influencers.filter( - influencerField => - typeof influencerField === 'string' && - !excludedKeywords.has(influencerField) && - !!influencerField && - !overallCardinalityFields.has(influencerField) - ) as string[]; - - if (overallCardinalityFields.size > 0) { - overallCardinality = await fieldsService.getCardinalityOfFields( - indexPattern, - [...overallCardinalityFields], - query, - timeFieldName, - earliestMs, - latestMs + return async ( + analysisConfig: AnalysisConfig, + indexPattern: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number + ): Promise<{ + overallCardinality: { [key: string]: number }; + maxBucketCardinality: { [key: string]: number }; + }> => { + /** + * Fields not involved in cardinality check + */ + const excludedKeywords = new Set( + /** + * The keyword which is used to mean the output of categorization, + * so it will have cardinality zero in the actual input data. + */ + MLCATEGORY ); - } - if (maxBucketFieldCardinalities.length > 0) { - maxBucketCardinality = await fieldsService.getMaxBucketCardinalities( - indexPattern, - maxBucketFieldCardinalities, - query, - timeFieldName, - earliestMs, - latestMs, - bucketSpan + const { detectors, influencers, bucket_span: bucketSpan } = analysisConfig; + + let overallCardinality = {}; + let maxBucketCardinality = {}; + + // Get fields required for the model memory estimation + const overallCardinalityFields: Set = detectors.reduce( + ( + acc, + { + by_field_name: byFieldName, + partition_field_name: partitionFieldName, + over_field_name: overFieldName, + } + ) => { + [byFieldName, partitionFieldName, overFieldName] + .filter(field => field !== undefined && field !== '' && !excludedKeywords.has(field)) + .forEach(key => { + acc.add(key as string); + }); + return acc; + }, + new Set() ); - } - return { - overallCardinality, - maxBucketCardinality, + const maxBucketFieldCardinalities: string[] = influencers.filter( + influencerField => + !!influencerField && + !excludedKeywords.has(influencerField) && + !overallCardinalityFields.has(influencerField) + ) as string[]; + + if (overallCardinalityFields.size > 0) { + overallCardinality = await fieldsService.getCardinalityOfFields( + indexPattern, + [...overallCardinalityFields], + query, + timeFieldName, + earliestMs, + latestMs + ); + } + + if (maxBucketFieldCardinalities.length > 0) { + maxBucketCardinality = await fieldsService.getMaxBucketCardinalities( + indexPattern, + maxBucketFieldCardinalities, + query, + timeFieldName, + earliestMs, + latestMs, + bucketSpan + ); + } + + return { + overallCardinality, + maxBucketCardinality, + }; }; -} +}; export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) { + const getCardinalities = cardinalityCheckProvider(callAsCurrentUser); + /** * Retrieves an estimated size of the model memory limit used in the job config * based on the cardinality of the fields being used to split the data @@ -145,7 +150,6 @@ export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) } const { overallCardinality, maxBucketCardinality } = await getCardinalities( - callAsCurrentUser, analysisConfig, indexPattern, query, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index a54c2f22a7951..824f9cc57982c 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -7,10 +7,12 @@ import fs from 'fs'; import Boom from 'boom'; import numeral from '@elastic/numeral'; -import { CallAPIOptions, APICaller, SavedObjectsClientContract } from 'kibana/server'; +import { APICaller, SavedObjectsClientContract } from 'kibana/server'; +import moment from 'moment'; import { IndexPatternAttributes } from 'src/plugins/data/server'; import { merge } from 'lodash'; -import { CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; +import { AnalysisLimits, CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; +import { MlInfoResponse } from '../../../common/types/ml_server_info'; import { KibanaObjects, ModuleDataFeed, @@ -18,14 +20,19 @@ import { Module, JobOverride, DatafeedOverride, - GeneralOverride, + GeneralJobsOverride, DatafeedResponse, JobResponse, KibanaObjectResponse, DataRecognizerConfigResponse, + GeneralDatafeedsOverride, + JobSpecificOverride, + isGeneralJobOverride, } from '../../../common/types/modules'; import { getLatestDataOrBucketTimestamp, prefixDatafeedId } from '../../../common/util/job_utils'; import { mlLog } from '../../client/log'; +import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_limit'; +import { fieldsServiceProvider } from '../fields_service'; import { jobServiceProvider } from '../job_service'; import { resultsServiceProvider } from '../results_service'; @@ -107,18 +114,15 @@ export class DataRecognizer { modulesDir = `${__dirname}/modules`; indexPatternName: string = ''; indexPatternId: string | undefined = undefined; - savedObjectsClient: SavedObjectsClientContract; + /** + * List of the module jobs that require model memory estimation + */ + jobsForModelMemoryEstimation: ModuleJob[] = []; - callAsCurrentUser: ( - endpoint: string, - clientParams?: Record, - options?: CallAPIOptions - ) => Promise; - - constructor(callAsCurrentUser: APICaller, savedObjectsClient: SavedObjectsClientContract) { - this.callAsCurrentUser = callAsCurrentUser; - this.savedObjectsClient = savedObjectsClient; - } + constructor( + private callAsCurrentUser: APICaller, + private savedObjectsClient: SavedObjectsClientContract + ) {} // list all directories under the given directory async listDirs(dirName: string): Promise { @@ -367,16 +371,17 @@ export class DataRecognizer { // if any of the savedObjects already exist, they will not be overwritten. async setupModuleItems( moduleId: string, - jobPrefix: string, - groups: string[], - indexPatternName: string, - query: any, - useDedicatedIndex: boolean, - startDatafeed: boolean, - start: number, - end: number, - jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + jobPrefix?: string, + groups?: string[], + indexPatternName?: string, + query?: any, + useDedicatedIndex?: boolean, + startDatafeed?: boolean, + start?: number, + end?: number, + jobOverrides?: JobOverride | JobOverride[], + datafeedOverrides?: DatafeedOverride | DatafeedOverride[], + estimateModelMemory?: boolean ) { // load the config from disk const moduleConfig = await this.getModule(moduleId, jobPrefix); @@ -418,11 +423,13 @@ export class DataRecognizer { savedObjects: [] as KibanaObjectResponse[], }; + this.jobsForModelMemoryEstimation = moduleConfig.jobs; + this.applyJobConfigOverrides(moduleConfig, jobOverrides, jobPrefix); this.applyDatafeedConfigOverrides(moduleConfig, datafeedOverrides, jobPrefix); this.updateDatafeedIndices(moduleConfig); this.updateJobUrlIndexPatterns(moduleConfig); - await this.updateModelMemoryLimits(moduleConfig); + await this.updateModelMemoryLimits(moduleConfig, estimateModelMemory, start, end); // create the jobs if (moduleConfig.jobs && moduleConfig.jobs.length) { @@ -689,8 +696,8 @@ export class DataRecognizer { async startDatafeeds( datafeeds: ModuleDataFeed[], - start: number, - end: number + start?: number, + end?: number ): Promise<{ [key: string]: DatafeedResponse }> { const results = {} as { [key: string]: DatafeedResponse }; for (const datafeed of datafeeds) { @@ -933,28 +940,117 @@ export class DataRecognizer { } } - // ensure the model memory limit for each job is not greater than - // the max model memory setting for the cluster - async updateModelMemoryLimits(moduleConfig: Module) { - const { limits } = await this.callAsCurrentUser('ml.info'); + /** + * Provides a time range of the last 3 months of data + */ + async getFallbackTimeRange( + timeField: string, + query?: any + ): Promise<{ start: number; end: number }> { + const fieldsService = fieldsServiceProvider(this.callAsCurrentUser); + + const timeFieldRange = await fieldsService.getTimeFieldRange( + this.indexPatternName, + timeField, + query + ); + + return { + start: timeFieldRange.end.epoch - moment.duration(3, 'months').asMilliseconds(), + end: timeFieldRange.end.epoch, + }; + } + + /** + * Ensure the model memory limit for each job is not greater than + * the max model memory setting for the cluster + */ + async updateModelMemoryLimits( + moduleConfig: Module, + estimateMML: boolean = false, + start?: number, + end?: number + ) { + if (!Array.isArray(moduleConfig.jobs)) { + return; + } + + if (estimateMML && this.jobsForModelMemoryEstimation.length > 0) { + const calculateModelMemoryLimit = calculateModelMemoryLimitProvider(this.callAsCurrentUser); + const query = moduleConfig.query ?? null; + + // Checks if all jobs in the module have the same time field configured + const isSameTimeFields = this.jobsForModelMemoryEstimation.every( + job => + job.config.data_description.time_field === + this.jobsForModelMemoryEstimation[0].config.data_description.time_field + ); + + if (isSameTimeFields && (start === undefined || end === undefined)) { + // In case of time range is not provided and the time field is the same + // set the fallback range for all jobs + const { start: fallbackStart, end: fallbackEnd } = await this.getFallbackTimeRange( + this.jobsForModelMemoryEstimation[0].config.data_description.time_field, + query + ); + start = fallbackStart; + end = fallbackEnd; + } + + for (const job of this.jobsForModelMemoryEstimation) { + let earliestMs = start; + let latestMs = end; + if (earliestMs === undefined || latestMs === undefined) { + const timeFieldRange = await this.getFallbackTimeRange( + job.config.data_description.time_field, + query + ); + earliestMs = timeFieldRange.start; + latestMs = timeFieldRange.end; + } + + const { modelMemoryLimit } = await calculateModelMemoryLimit( + job.config.analysis_config, + this.indexPatternName, + query, + job.config.data_description.time_field, + earliestMs, + latestMs + ); + + if (!job.config.analysis_limits) { + job.config.analysis_limits = {} as AnalysisLimits; + } + + job.config.analysis_limits.model_memory_limit = modelMemoryLimit; + } + } + + const { limits } = await this.callAsCurrentUser('ml.info'); const maxMml = limits.max_model_memory_limit; - if (maxMml !== undefined) { - // @ts-ignore - const maxBytes: number = numeral(maxMml.toUpperCase()).value(); - - if (Array.isArray(moduleConfig.jobs)) { - moduleConfig.jobs.forEach(job => { - const mml = job.config?.analysis_limits?.model_memory_limit; - if (mml !== undefined) { - // @ts-ignore - const mmlBytes: number = numeral(mml.toUpperCase()).value(); - if (mmlBytes > maxBytes) { - // if the job's mml is over the max, - // so set the jobs mml to be the max - job.config.analysis_limits!.model_memory_limit = maxMml; - } + + if (!maxMml) { + return; + } + + // @ts-ignore + const maxBytes: number = numeral(maxMml.toUpperCase()).value(); + + for (const job of moduleConfig.jobs) { + const mml = job.config?.analysis_limits?.model_memory_limit; + if (mml !== undefined) { + // @ts-ignore + const mmlBytes: number = numeral(mml.toUpperCase()).value(); + if (mmlBytes > maxBytes) { + // if the job's mml is over the max, + // so set the jobs mml to be the max + + if (!job.config.analysis_limits) { + job.config.analysis_limits = {} as AnalysisLimits; } - }); + + job.config.analysis_limits.model_memory_limit = maxMml; + } } } } @@ -975,7 +1071,11 @@ export class DataRecognizer { return false; } - applyJobConfigOverrides(moduleConfig: Module, jobOverrides: JobOverride[], jobPrefix = '') { + applyJobConfigOverrides( + moduleConfig: Module, + jobOverrides?: JobOverride | JobOverride[], + jobPrefix = '' + ) { if (jobOverrides === undefined || jobOverrides === null) { return; } @@ -993,17 +1093,26 @@ export class DataRecognizer { // separate all the overrides. // the overrides which don't contain a job id will be applied to all jobs in the module - const generalOverrides: GeneralOverride[] = []; - const jobSpecificOverrides: JobOverride[] = []; + const generalOverrides: GeneralJobsOverride[] = []; + const jobSpecificOverrides: JobSpecificOverride[] = []; overrides.forEach(override => { - if (override.job_id === undefined) { + if (isGeneralJobOverride(override)) { generalOverrides.push(override); } else { jobSpecificOverrides.push(override); } }); + if (generalOverrides.some(override => !!override.analysis_limits?.model_memory_limit)) { + this.jobsForModelMemoryEstimation = []; + } else { + this.jobsForModelMemoryEstimation = moduleConfig.jobs.filter(job => { + const override = jobSpecificOverrides.find(o => `${jobPrefix}${o.job_id}` === job.id); + return override?.analysis_limits?.model_memory_limit === undefined; + }); + } + function processArrayValues(source: any, update: any) { if (typeof source !== 'object' || typeof update !== 'object') { return; @@ -1052,7 +1161,7 @@ export class DataRecognizer { applyDatafeedConfigOverrides( moduleConfig: Module, - datafeedOverrides: DatafeedOverride | DatafeedOverride[], + datafeedOverrides?: DatafeedOverride | DatafeedOverride[], jobPrefix = '' ) { if (datafeedOverrides !== undefined && datafeedOverrides !== null) { @@ -1069,7 +1178,7 @@ export class DataRecognizer { // separate all the overrides. // the overrides which don't contain a datafeed id or a job id will be applied to all jobs in the module - const generalOverrides: GeneralOverride[] = []; + const generalOverrides: GeneralDatafeedsOverride[] = []; const datafeedSpecificOverrides: DatafeedOverride[] = []; overrides.forEach(o => { if (o.datafeed_id === undefined && o.job_id === undefined) { diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts b/x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts new file mode 100644 index 0000000000000..cdaefe6fdeed7 --- /dev/null +++ b/x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick } from 'lodash'; + +/** + * Cached aggregation types + */ +type AggType = 'overallCardinality' | 'maxBucketCardinality'; + +type CacheStorage = { [key in AggType]: { [field: string]: number } }; + +/** + * Caches cardinality fields values to avoid + * unnecessary aggregations on elasticsearch + */ +export const initCardinalityFieldsCache = () => { + const cardinalityCache = new Map(); + + return { + /** + * Gets requested values from cache + */ + getValues( + indexPatternName: string | string[], + timeField: string, + earliestMs: number, + latestMs: number, + aggType: AggType, + fieldNames: string[] + ): CacheStorage[AggType] | null { + const cacheKey = indexPatternName + timeField + earliestMs + latestMs; + const cached = cardinalityCache.get(cacheKey); + if (!cached) { + return null; + } + return pick(cached[aggType], fieldNames); + }, + /** + * Extends cache with provided values + */ + updateValues( + indexPatternName: string | string[], + timeField: string, + earliestMs: number, + latestMs: number, + update: Partial + ): void { + const cacheKey = indexPatternName + timeField + earliestMs + latestMs; + const cachedValues = cardinalityCache.get(cacheKey); + if (cachedValues === undefined) { + cardinalityCache.set(cacheKey, { + overallCardinality: update.overallCardinality ?? {}, + maxBucketCardinality: update.maxBucketCardinality ?? {}, + }); + return; + } + + Object.assign(cachedValues.overallCardinality, update.overallCardinality); + Object.assign(cachedValues.maxBucketCardinality, update.maxBucketCardinality); + }, + }; +}; diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts index d16984abc5d2a..567c5d2afb7de 100644 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -7,12 +7,15 @@ import Boom from 'boom'; import { APICaller } from 'kibana/server'; import { parseInterval } from '../../../common/util/parse_interval'; +import { initCardinalityFieldsCache } from './fields_aggs_cache'; /** * Service for carrying out queries to obtain data * specific to fields in Elasticsearch indices. */ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { + const fieldsAggsCache = initCardinalityFieldsCache(); + /** * Gets aggregatable fields. */ @@ -58,6 +61,23 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } + const cachedValues = + fieldsAggsCache.getValues( + index, + timeFieldName, + earliestMs, + latestMs, + 'overallCardinality', + fieldNames + ) ?? {}; + + // No need to perform aggregation over the cached fields + const fieldsToAgg = aggregatableFields.filter(field => !cachedValues.hasOwnProperty(field)); + + if (fieldsToAgg.length === 0) { + return cachedValues; + } + // Build the criteria to use in the bool filter part of the request. // Add criteria for the time range and the datafeed config query. const mustCriteria = [ @@ -76,7 +96,7 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { mustCriteria.push(query); } - const aggs = aggregatableFields.reduce((obj, field) => { + const aggs = fieldsToAgg.reduce((obj, field) => { obj[field] = { cardinality: { field } }; return obj; }, {} as { [field: string]: { cardinality: { field: string } } }); @@ -105,53 +125,63 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } - return aggregatableFields.reduce((obj, field) => { + const aggResult = fieldsToAgg.reduce((obj, field) => { obj[field] = (aggregations[field] || { value: 0 }).value; return obj; }, {} as { [field: string]: number }); + + fieldsAggsCache.updateValues(index, timeFieldName, earliestMs, latestMs, { + overallCardinality: aggResult, + }); + + return { + ...cachedValues, + ...aggResult, + }; } - function getTimeFieldRange( + /** + * Gets time boundaries of the index data based on the provided time field. + */ + async function getTimeFieldRange( index: string[] | string, timeFieldName: string, query: any - ): Promise { - return new Promise((resolve, reject) => { - const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; - - callAsCurrentUser('search', { - index, - size: 0, - body: { - query, - aggs: { - earliest: { - min: { - field: timeFieldName, - }, + ): Promise<{ + success: boolean; + start: { epoch: number; string: string }; + end: { epoch: number; string: string }; + }> { + const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; + + const resp = await callAsCurrentUser('search', { + index, + size: 0, + body: { + ...(query ? { query } : {}), + aggs: { + earliest: { + min: { + field: timeFieldName, }, - latest: { - max: { - field: timeFieldName, - }, + }, + latest: { + max: { + field: timeFieldName, }, }, }, - }) - .then(resp => { - if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { - obj.start.epoch = resp.aggregations.earliest.value; - obj.start.string = resp.aggregations.earliest.value_as_string; - - obj.end.epoch = resp.aggregations.latest.value; - obj.end.string = resp.aggregations.latest.value_as_string; - } - resolve(obj); - }) - .catch(resp => { - reject(resp); - }); + }, }); + + if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { + obj.start.epoch = resp.aggregations.earliest.value; + obj.start.string = resp.aggregations.earliest.value_as_string; + + obj.end.epoch = resp.aggregations.latest.value; + obj.end.string = resp.aggregations.latest.value_as_string; + } + return obj; } /** @@ -213,6 +243,23 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } + const cachedValues = + fieldsAggsCache.getValues( + index, + timeFieldName, + earliestMs, + latestMs, + 'maxBucketCardinality', + fieldNames + ) ?? {}; + + // No need to perform aggregation over the cached fields + const fieldsToAgg = aggregatableFields.filter(field => !cachedValues.hasOwnProperty(field)); + + if (fieldsToAgg.length === 0) { + return cachedValues; + } + const { start, end } = getSafeTimeRange(earliestMs, latestMs, interval); const mustCriteria = [ @@ -238,7 +285,7 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { const getSafeAggName = (field: string) => field.replace(/\W/g, ''); const getMaxBucketAggKey = (field: string) => `max_bucket_${field}`; - const fieldsCardinalityAggs = aggregatableFields.reduce((obj, field) => { + const fieldsCardinalityAggs = fieldsToAgg.reduce((obj, field) => { obj[getSafeAggName(field)] = { cardinality: { field } }; return obj; }, {} as { [field: string]: { cardinality: { field: string } } }); @@ -279,13 +326,18 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { )?.aggregations; if (!aggregations) { - return {}; + return cachedValues; } - return aggregatableFields.reduce((obj, field) => { + const aggResult = fieldsToAgg.reduce((obj, field) => { obj[field] = (aggregations[getMaxBucketAggKey(field)] || { value: 0 }).value ?? 0; return obj; }, {} as { [field: string]: number }); + + return { + ...cachedValues, + ...aggResult, + }; } return { diff --git a/x-pack/plugins/ml/server/routes/modules.ts b/x-pack/plugins/ml/server/routes/modules.ts index 685119672a983..358cd0ac2871c 100644 --- a/x-pack/plugins/ml/server/routes/modules.ts +++ b/x-pack/plugins/ml/server/routes/modules.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import { RequestHandlerContext } from 'kibana/server'; import { DatafeedOverride, JobOverride } from '../../common/types/modules'; @@ -36,16 +36,17 @@ function getModule(context: RequestHandlerContext, moduleId: string) { function saveModuleItems( context: RequestHandlerContext, moduleId: string, - prefix: string, - groups: string[], - indexPatternName: string, - query: any, - useDedicatedIndex: boolean, - startDatafeed: boolean, - start: number, - end: number, - jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + prefix?: string, + groups?: string[], + indexPatternName?: string, + query?: any, + useDedicatedIndex?: boolean, + startDatafeed?: boolean, + start?: number, + end?: number, + jobOverrides?: JobOverride | JobOverride[], + datafeedOverrides?: DatafeedOverride | DatafeedOverride[], + estimateModelMemory?: boolean ) { const dr = new DataRecognizer( context.ml!.mlClient.callAsCurrentUser, @@ -62,7 +63,8 @@ function saveModuleItems( start, end, jobOverrides, - datafeedOverrides + datafeedOverrides, + estimateModelMemory ); } @@ -156,9 +158,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/modules/setup/{moduleId}', validate: { - params: schema.object({ - ...getModuleIdParamSchema(), - }), + params: schema.object(getModuleIdParamSchema()), body: setupModuleBodySchema, }, }, @@ -177,7 +177,8 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { end, jobOverrides, datafeedOverrides, - } = request.body; + estimateModelMemory, + } = request.body as TypeOf; const result = await saveModuleItems( context, @@ -191,7 +192,8 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { start, end, jobOverrides, - datafeedOverrides + datafeedOverrides, + estimateModelMemory ); return response.ok({ body: result }); @@ -214,9 +216,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/modules/jobs_exist/{moduleId}', validate: { - params: schema.object({ - ...getModuleIdParamSchema(), - }), + params: schema.object(getModuleIdParamSchema()), }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/schemas/modules.ts b/x-pack/plugins/ml/server/routes/schemas/modules.ts index 46b7e53c22a05..98e3d80f0ff84 100644 --- a/x-pack/plugins/ml/server/routes/schemas/modules.ts +++ b/x-pack/plugins/ml/server/routes/schemas/modules.ts @@ -17,6 +17,11 @@ export const setupModuleBodySchema = schema.object({ end: schema.maybe(schema.number()), jobOverrides: schema.maybe(schema.any()), datafeedOverrides: schema.maybe(schema.any()), + /** + * Indicates whether an estimate of the model memory limit + * should be made by checking the cardinality of fields in the job configurations. + */ + estimateModelMemory: schema.maybe(schema.boolean()), }); export const getModuleIdParamSchema = (optional = false) => { diff --git a/x-pack/plugins/ml/server/shared_services/providers/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/modules.ts index ffc977917ae46..ec876273c2c33 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/modules.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/modules.ts @@ -32,7 +32,8 @@ export interface ModulesProvider { start: number, end: number, jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + datafeedOverrides: DatafeedOverride[], + estimateModelMemory?: boolean ): Promise; }; } @@ -65,7 +66,8 @@ export function getModulesProvider(isFullLicense: LicenseCheck): ModulesProvider start: number, end: number, jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + datafeedOverrides: DatafeedOverride[], + estimateModelMemory?: boolean ) { const dr = dataRecognizerFactory(callAsCurrentUser, savedObjectsClient); return dr.setupModuleItems( @@ -79,7 +81,8 @@ export function getModulesProvider(isFullLicense: LicenseCheck): ModulesProvider start, end, jobOverrides, - datafeedOverrides + datafeedOverrides, + estimateModelMemory ); }, }; From 8f55a8edc79873bae3a0eb8b61d43f32adde94b3 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Tue, 24 Mar 2020 11:19:22 +0300 Subject: [PATCH 17/64] Migrated styles for "share" plugin to new platform (#59981) Co-authored-by: Elastic Machine --- src/legacy/ui/public/_index.scss | 1 - .../share => plugins/share/public/components}/_index.scss | 0 .../share/public/components}/_share_context_menu.scss | 0 src/plugins/share/public/index.scss | 1 + src/plugins/share/public/plugin.ts | 2 ++ 5 files changed, 3 insertions(+), 1 deletion(-) rename src/{legacy/ui/public/share => plugins/share/public/components}/_index.scss (100%) rename src/{legacy/ui/public/share => plugins/share/public/components}/_share_context_menu.scss (100%) create mode 100644 src/plugins/share/public/index.scss diff --git a/src/legacy/ui/public/_index.scss b/src/legacy/ui/public/_index.scss index 3c3067776a161..87006d9347de4 100644 --- a/src/legacy/ui/public/_index.scss +++ b/src/legacy/ui/public/_index.scss @@ -15,7 +15,6 @@ @import './error_url_overflow/index'; @import './exit_full_screen/index'; @import './field_editor/index'; -@import './share/index'; @import './style_compile/index'; @import '../../../plugins/management/public/components/index'; diff --git a/src/legacy/ui/public/share/_index.scss b/src/plugins/share/public/components/_index.scss similarity index 100% rename from src/legacy/ui/public/share/_index.scss rename to src/plugins/share/public/components/_index.scss diff --git a/src/legacy/ui/public/share/_share_context_menu.scss b/src/plugins/share/public/components/_share_context_menu.scss similarity index 100% rename from src/legacy/ui/public/share/_share_context_menu.scss rename to src/plugins/share/public/components/_share_context_menu.scss diff --git a/src/plugins/share/public/index.scss b/src/plugins/share/public/index.scss new file mode 100644 index 0000000000000..0271fbb8e9026 --- /dev/null +++ b/src/plugins/share/public/index.scss @@ -0,0 +1 @@ +@import './components/index' \ No newline at end of file diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 5b638174b4dfb..d02f51af42905 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -17,6 +17,8 @@ * under the License. */ +import './index.scss'; + import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { ShareMenuManager, ShareMenuManagerStart } from './services'; import { ShareMenuRegistry, ShareMenuRegistrySetup } from './services'; From c26493d56c0b93c9baa7a30a0e1cb65b86f63636 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Tue, 24 Mar 2020 11:19:42 +0300 Subject: [PATCH 18/64] [App Arch] migrate legacy CSS to new platform (core_plugins/kibana_react) (#59882) * Migrate markdown styles to the new platform * Removed unused import * Update index.ts * Removed not need layer * Fixed paths Co-authored-by: Elastic Machine --- .../np_ready/angular/context/query/actions.js | 2 +- src/legacy/core_plugins/kibana_react/index.ts | 41 ------------------- .../core_plugins/kibana_react/package.json | 4 -- .../kibana_react/public/index.scss | 3 -- .../core_plugins/kibana_react/public/index.ts | 20 --------- .../public/markdown_vis_controller.tsx | 2 +- .../components/vis_types/markdown/vis.js | 2 +- .../components/vis_types/timeseries/vis.js | 2 +- .../public/markdown/_markdown.scss | 0 .../kibana_react/public/markdown/index.scss} | 0 .../public/markdown/{index.ts => index.tsx} | 0 .../kibana_react/public/markdown/markdown.tsx | 1 + .../renderers/markdown/index.js | 2 +- .../license/logstash_license_service.js | 2 +- 14 files changed, 7 insertions(+), 74 deletions(-) delete mode 100644 src/legacy/core_plugins/kibana_react/index.ts delete mode 100644 src/legacy/core_plugins/kibana_react/package.json delete mode 100644 src/legacy/core_plugins/kibana_react/public/index.scss delete mode 100644 src/legacy/core_plugins/kibana_react/public/index.ts rename src/{legacy/core_plugins => plugins}/kibana_react/public/markdown/_markdown.scss (100%) rename src/{legacy/core_plugins/kibana_react/public/markdown/_index.scss => plugins/kibana_react/public/markdown/index.scss} (100%) rename src/plugins/kibana_react/public/markdown/{index.ts => index.tsx} (100%) diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js index 674f40d0186e5..9efddc5275069 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js @@ -26,7 +26,7 @@ import { fetchAnchorProvider } from '../api/anchor'; import { fetchContextProvider } from '../api/context'; import { getQueryParameterActions } from '../query_parameters'; import { FAILURE_REASONS, LOADING_STATUS } from './constants'; -import { MarkdownSimple } from '../../../../../../../kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../../../plugins/kibana_react/public'; export function QueryActionsProvider(Promise) { const { filterManager, indexPatterns } = getServices(); diff --git a/src/legacy/core_plugins/kibana_react/index.ts b/src/legacy/core_plugins/kibana_react/index.ts deleted file mode 100644 index f4083f3d50c34..0000000000000 --- a/src/legacy/core_plugins/kibana_react/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'path'; -import { Legacy } from '../../../../kibana'; - -// eslint-disable-next-line import/no-default-export -export default function DataPlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'kibana_react', - require: [], - config: (Joi: any) => { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - init: (server: Legacy.Server) => ({}), - uiExports: { - injectDefaultVars: () => ({}), - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - }, - }; - - return new kibana.Plugin(config); -} diff --git a/src/legacy/core_plugins/kibana_react/package.json b/src/legacy/core_plugins/kibana_react/package.json deleted file mode 100644 index 3f7cf717a1963..0000000000000 --- a/src/legacy/core_plugins/kibana_react/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "kibana_react", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/kibana_react/public/index.scss b/src/legacy/core_plugins/kibana_react/public/index.scss deleted file mode 100644 index 14b4687c459e1..0000000000000 --- a/src/legacy/core_plugins/kibana_react/public/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import 'src/legacy/ui/public/styles/styling_constants'; - -@import './markdown/index'; diff --git a/src/legacy/core_plugins/kibana_react/public/index.ts b/src/legacy/core_plugins/kibana_react/public/index.ts deleted file mode 100644 index a6a7cb72a8dee..0000000000000 --- a/src/legacy/core_plugins/kibana_react/public/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { Markdown, MarkdownSimple } from '../../../../plugins/kibana_react/public'; diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx index 4e77bb196b713..3260e9f7d8091 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; -import { Markdown } from '../../kibana_react/public'; +import { Markdown } from '../../../../plugins/kibana_react/public'; import { MarkdownVisParams } from './types'; interface MarkdownVisComponentProps extends MarkdownVisParams { diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js index a806339085450..d8bcf56b48cb9 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js @@ -21,7 +21,7 @@ import React from 'react'; import classNames from 'classnames'; import uuid from 'uuid'; import { get } from 'lodash'; -import { Markdown } from '../../../../../kibana_react/public'; +import { Markdown } from '../../../../../../../plugins/kibana_react/public'; import { ErrorComponent } from '../../error'; import { replaceVars } from '../../lib/replace_vars'; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js index 356ba08ac2427..f559bc38b6c58 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js @@ -27,7 +27,7 @@ import { ScaleType } from '@elastic/charts'; import { createTickFormatter } from '../../lib/tick_formatter'; import { TimeSeries } from '../../../visualizations/views/timeseries'; -import { MarkdownSimple } from '../../../../../kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../plugins/kibana_react/public'; import { replaceVars } from '../../lib/replace_vars'; import { getAxisLabelString } from '../../lib/get_axis_label_string'; import { getInterval } from '../../lib/get_interval'; diff --git a/src/legacy/core_plugins/kibana_react/public/markdown/_markdown.scss b/src/plugins/kibana_react/public/markdown/_markdown.scss similarity index 100% rename from src/legacy/core_plugins/kibana_react/public/markdown/_markdown.scss rename to src/plugins/kibana_react/public/markdown/_markdown.scss diff --git a/src/legacy/core_plugins/kibana_react/public/markdown/_index.scss b/src/plugins/kibana_react/public/markdown/index.scss similarity index 100% rename from src/legacy/core_plugins/kibana_react/public/markdown/_index.scss rename to src/plugins/kibana_react/public/markdown/index.scss diff --git a/src/plugins/kibana_react/public/markdown/index.ts b/src/plugins/kibana_react/public/markdown/index.tsx similarity index 100% rename from src/plugins/kibana_react/public/markdown/index.ts rename to src/plugins/kibana_react/public/markdown/index.tsx diff --git a/src/plugins/kibana_react/public/markdown/markdown.tsx b/src/plugins/kibana_react/public/markdown/markdown.tsx index ba81b5e111cbd..a0c2cdad78c66 100644 --- a/src/plugins/kibana_react/public/markdown/markdown.tsx +++ b/src/plugins/kibana_react/public/markdown/markdown.tsx @@ -23,6 +23,7 @@ import MarkdownIt from 'markdown-it'; import { memoize } from 'lodash'; import { getSecureRelForTarget } from '@elastic/eui'; +import './index.scss'; /** * Return a memoized markdown rendering function that use the specified * whiteListedRules and openLinksInNewTab configurations. diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js index c1bfd7c99ac41..126699534caad 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js @@ -7,7 +7,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { RendererStrings } from '../../../i18n'; -import { Markdown } from '../../../../../../../src/legacy/core_plugins/kibana_react/public'; +import { Markdown } from '../../../../../../../src/plugins/kibana_react/public'; const { markdown: strings } = RendererStrings; diff --git a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js index 795899ff32f97..97b336ec0728b 100755 --- a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js @@ -6,7 +6,7 @@ import React from 'react'; import { toastNotifications } from 'ui/notify'; -import { MarkdownSimple } from '../../../../../../../src/legacy/core_plugins/kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../src/plugins/kibana_react/public'; import { PLUGIN } from '../../../common/constants'; export class LogstashLicenseService { From 8ef35c8f8726278adbd8a354dc992f4bfd559262 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 24 Mar 2020 09:28:29 +0100 Subject: [PATCH 19/64] [APM] add service map config options to legacy plugin (#61002) --- x-pack/legacy/plugins/apm/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 0107997f233fe..6f238b48d9465 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -71,7 +71,12 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true), + serviceMapFingerprintBucketSize: Joi.number().default(100), + serviceMapTraceIdBucketSize: Joi.number().default(65), + serviceMapFingerprintGlobalBucketSize: Joi.number().default(1000), + serviceMapTraceIdGlobalBucketSize: Joi.number().default(6), + serviceMapMaxTracesPerRequest: Joi.number().default(50) }).default(); }, From a93efedcc55f9c664f2373afd722e8399257b895 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Tue, 24 Mar 2020 09:46:59 +0100 Subject: [PATCH 20/64] Cahgen save object duplicate message (#60901) --- .../saved_objects/public/save_modal/saved_object_save_modal.tsx | 2 +- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 1d145bc97bdb4..95eb56c0e874b 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -289,7 +289,7 @@ export class SavedObjectSaveModal extends React.Component {

Date: Tue, 24 Mar 2020 09:43:11 +0000 Subject: [PATCH 21/64] [ML] Add support for percentiles aggregation to Transform wizard (#60763) * [ML] Add support for percentiles aggregation to Transform wizard * [ML] Fix type error and comments from review * [ML] Remove unused function Co-authored-by: Elastic Machine --- .../transform/public/app/common/index.ts | 2 + .../transform/public/app/common/pivot_aggs.ts | 22 +++- .../aggregation_list/popover_form.tsx | 104 +++++++++++++++++- .../components/step_define/common.test.ts | 8 ++ .../components/step_define/common.ts | 35 +++++- .../apps/transform/creation_index_pattern.ts | 54 +++++++++ 6 files changed, 217 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/transform/public/app/common/index.ts b/x-pack/plugins/transform/public/app/common/index.ts index ee026e2e590a4..f2b31bb5da865 100644 --- a/x-pack/plugins/transform/public/app/common/index.ts +++ b/x-pack/plugins/transform/public/app/common/index.ts @@ -40,6 +40,8 @@ export { GetTransformsResponse, PreviewData, PreviewMappings } from './pivot_pre export { getEsAggFromAggConfig, isPivotAggsConfigWithUiSupport, + isPivotAggsConfigPercentiles, + PERCENTILES_AGG_DEFAULT_PERCENTS, PivotAgg, PivotAggDict, PivotAggsConfig, diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts index 3ea614aaf5c9a..35dad3a8b2153 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -15,10 +15,13 @@ export enum PIVOT_SUPPORTED_AGGS { CARDINALITY = 'cardinality', MAX = 'max', MIN = 'min', + PERCENTILES = 'percentiles', SUM = 'sum', VALUE_COUNT = 'value_count', } +export const PERCENTILES_AGG_DEFAULT_PERCENTS = [1, 5, 25, 50, 75, 95, 99]; + export const pivotAggsFieldSupport = { [KBN_FIELD_TYPES.ATTACHMENT]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT], [KBN_FIELD_TYPES.BOOLEAN]: [PIVOT_SUPPORTED_AGGS.VALUE_COUNT], @@ -36,6 +39,7 @@ export const pivotAggsFieldSupport = { PIVOT_SUPPORTED_AGGS.CARDINALITY, PIVOT_SUPPORTED_AGGS.MAX, PIVOT_SUPPORTED_AGGS.MIN, + PIVOT_SUPPORTED_AGGS.PERCENTILES, PIVOT_SUPPORTED_AGGS.SUM, PIVOT_SUPPORTED_AGGS.VALUE_COUNT, ], @@ -60,10 +64,17 @@ export interface PivotAggsConfigBase { dropDownName: string; } -export interface PivotAggsConfigWithUiSupport extends PivotAggsConfigBase { +interface PivotAggsConfigWithUiBase extends PivotAggsConfigBase { field: EsFieldName; } +interface PivotAggsConfigPercentiles extends PivotAggsConfigWithUiBase { + agg: PIVOT_SUPPORTED_AGGS.PERCENTILES; + percents: number[]; +} + +export type PivotAggsConfigWithUiSupport = PivotAggsConfigWithUiBase | PivotAggsConfigPercentiles; + export function isPivotAggsConfigWithUiSupport(arg: any): arg is PivotAggsConfigWithUiSupport { return ( arg.hasOwnProperty('agg') && @@ -74,6 +85,15 @@ export function isPivotAggsConfigWithUiSupport(arg: any): arg is PivotAggsConfig ); } +export function isPivotAggsConfigPercentiles(arg: any): arg is PivotAggsConfigPercentiles { + return ( + arg.hasOwnProperty('agg') && + arg.hasOwnProperty('field') && + arg.hasOwnProperty('percents') && + arg.agg === PIVOT_SUPPORTED_AGGS.PERCENTILES + ); +} + export type PivotAggsConfig = PivotAggsConfigBase | PivotAggsConfigWithUiSupport; export type PivotAggsConfigWithUiSupportDict = Dictionary; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx index 6c1e119ab38e0..7157586dddda9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx @@ -22,8 +22,10 @@ import { dictionaryToArray } from '../../../../../../common/types/common'; import { AggName, isAggName, + isPivotAggsConfigPercentiles, isPivotAggsConfigWithUiSupport, getEsAggFromAggConfig, + PERCENTILES_AGG_DEFAULT_PERCENTS, PivotAggsConfig, PivotAggsConfigWithUiSupportDict, PIVOT_SUPPORTED_AGGS, @@ -40,6 +42,33 @@ interface Props { onChange(d: PivotAggsConfig): void; } +function getDefaultPercents(defaultData: PivotAggsConfig): number[] | undefined { + if (isPivotAggsConfigPercentiles(defaultData)) { + return defaultData.percents; + } +} + +function parsePercentsInput(inputValue: string | undefined) { + if (inputValue !== undefined) { + const strVals: string[] = inputValue.split(','); + const percents: number[] = []; + for (const str of strVals) { + if (str.trim().length > 0 && isNaN(str as any) === false) { + const val = Number(str); + if (val >= 0 && val <= 100) { + percents.push(val); + } else { + return []; + } + } + } + + return percents; + } + + return []; +} + export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onChange, options }) => { const isUnsupportedAgg = !isPivotAggsConfigWithUiSupport(defaultData); @@ -48,10 +77,45 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha const [field, setField] = useState( isPivotAggsConfigWithUiSupport(defaultData) ? defaultData.field : '' ); + const [percents, setPercents] = useState(getDefaultPercents(defaultData)); const availableFields: SelectOption[] = []; const availableAggs: SelectOption[] = []; + function updateAgg(aggVal: PIVOT_SUPPORTED_AGGS) { + setAgg(aggVal); + if (aggVal === PIVOT_SUPPORTED_AGGS.PERCENTILES && percents === undefined) { + setPercents(PERCENTILES_AGG_DEFAULT_PERCENTS); + } + } + + function updatePercents(inputValue: string) { + setPercents(parsePercentsInput(inputValue)); + } + + function getUpdatedItem(): PivotAggsConfig { + let updatedItem: PivotAggsConfig; + + if (agg !== PIVOT_SUPPORTED_AGGS.PERCENTILES) { + updatedItem = { + agg, + aggName, + field, + dropDownName: defaultData.dropDownName, + }; + } else { + updatedItem = { + agg, + aggName, + field, + dropDownName: defaultData.dropDownName, + percents, + }; + } + + return updatedItem; + } + if (!isUnsupportedAgg) { const optionsArr = dictionaryToArray(options); optionsArr @@ -83,7 +147,18 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha }); } - const formValid = validAggName; + let percentsText; + if (percents !== undefined) { + percentsText = percents.toString(); + } + + const validPercents = + agg === PIVOT_SUPPORTED_AGGS.PERCENTILES && parsePercentsInput(percentsText).length > 0; + + let formValid = validAggName; + if (formValid && agg === PIVOT_SUPPORTED_AGGS.PERCENTILES) { + formValid = validPercents; + } return ( @@ -117,7 +192,7 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha setAgg(e.target.value as PIVOT_SUPPORTED_AGGS)} + onChange={e => updateAgg(e.target.value as PIVOT_SUPPORTED_AGGS)} /> )} @@ -134,6 +209,26 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha /> )} + {agg === PIVOT_SUPPORTED_AGGS.PERCENTILES && ( + + updatePercents(e.target.value)} + /> + + )} {isUnsupportedAgg && ( = ({ defaultData, otherAggNames, onCha /> )} - onChange({ ...defaultData, aggName, agg, field })} - > + onChange(getUpdatedItem())}> {i18n.translate('xpack.transform.agg.popoverForm.submitButtonLabel', { defaultMessage: 'Apply', })} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts index 5db6a233c9134..58ab4a1b8ac33 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.test.ts @@ -37,6 +37,7 @@ describe('Transform: Define Pivot Common', () => { { label: 'cardinality( the-f[i]e>ld )' }, { label: 'max( the-f[i]e>ld )' }, { label: 'min( the-f[i]e>ld )' }, + { label: 'percentiles( the-f[i]e>ld )' }, { label: 'sum( the-f[i]e>ld )' }, { label: 'value_count( the-f[i]e>ld )' }, ], @@ -67,6 +68,13 @@ describe('Transform: Define Pivot Common', () => { aggName: 'the-field.min', dropDownName: 'min( the-f[i]e>ld )', }, + 'percentiles( the-f[i]e>ld )': { + agg: 'percentiles', + field: ' the-f[i]e>ld ', + aggName: 'the-field.percentiles', + dropDownName: 'percentiles( the-f[i]e>ld )', + percents: [1, 5, 25, 50, 75, 95, 99], + }, 'sum( the-f[i]e>ld )': { agg: 'sum', field: ' the-f[i]e>ld ', diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts index a9413afb6243e..65cea40276da9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common.ts @@ -12,10 +12,13 @@ import { DropDownOption, EsFieldName, GroupByConfigWithUiSupport, + PERCENTILES_AGG_DEFAULT_PERCENTS, + PivotAggsConfigWithUiSupport, PivotAggsConfigWithUiSupportDict, pivotAggsFieldSupport, PivotGroupByConfigWithUiSupportDict, pivotGroupByFieldSupport, + PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; @@ -57,6 +60,31 @@ function getDefaultGroupByConfig( } } +function getDefaultAggregationConfig( + aggName: string, + dropDownName: string, + fieldName: EsFieldName, + agg: PIVOT_SUPPORTED_AGGS +): PivotAggsConfigWithUiSupport { + switch (agg) { + case PIVOT_SUPPORTED_AGGS.PERCENTILES: + return { + agg, + aggName, + dropDownName, + field: fieldName, + percents: PERCENTILES_AGG_DEFAULT_PERCENTS, + }; + default: + return { + agg, + aggName, + dropDownName, + field: fieldName, + }; + } +} + const illegalEsAggNameChars = /[[\]>]/g; export function getPivotDropdownOptions(indexPattern: IndexPattern) { @@ -105,7 +133,12 @@ export function getPivotDropdownOptions(indexPattern: IndexPattern) { // Option name in the dropdown for the aggregation is in the form of `sum(fieldname)`. const dropDownName = `${agg}(${field.name})`; aggOption.options.push({ label: dropDownName }); - aggOptionsData[dropDownName] = { agg, field: field.name, aggName, dropDownName }; + aggOptionsData[dropDownName] = getDefaultAggregationConfig( + aggName, + dropDownName, + field.name, + agg + ); }); } aggOptions.push(aggOption); diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 4d1300ffaad06..ae3617db9e517 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -94,6 +94,60 @@ export default function({ getService }: FtrProviderContext) { }, }, }, + { + suiteTitle: 'batch transform with terms group and percentiles agg', + source: 'ecommerce', + groupByEntries: [ + { + identifier: 'terms(geoip.country_iso_code)', + label: 'geoip.country_iso_code', + } as GroupByEntry, + ], + aggregationEntries: [ + { + identifier: 'percentiles(products.base_price)', + label: 'products.base_price.percentiles', + }, + ], + transformId: `ec_2_${Date.now()}`, + transformDescription: + 'ecommerce batch transform with group by terms(geoip.country_iso_code) and aggregation percentiles(products.base_price)', + get destinationIndex(): string { + return `user-${this.transformId}`; + }, + expected: { + pivotAdvancedEditorValue: { + group_by: { + 'geoip.country_iso_code': { + terms: { + field: 'geoip.country_iso_code', + }, + }, + }, + aggregations: { + 'products.base_price.percentiles': { + percentiles: { + field: 'products.base_price', + percents: [1, 5, 25, 50, 75, 95, 99], + }, + }, + }, + }, + pivotPreview: { + column: 0, + values: ['AE', 'CO', 'EG', 'FR', 'GB'], + }, + row: { + status: 'stopped', + mode: 'batch', + progress: '100', + }, + sourcePreview: { + columns: 45, + rows: 5, + }, + }, + }, ]; for (const testData of testDataList) { From 85c0be357ae6b5ab6d0711bda5fe3c050f45fdaf Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 24 Mar 2020 11:15:10 +0100 Subject: [PATCH 22/64] [APM] Threshold alerts (#59566) * Add alerting/actions permissions for APM * Export TIME_UNITS, getTimeUnitLabel from triggers actions UI plugin * Add APM alert types and UI * Review feedback * Use Expression components for triggers * Update alert name for transaction duration * Change defaults for error rate trigger --- x-pack/legacy/plugins/apm/index.ts | 22 ++- .../__test__/APMIndicesPermission.test.tsx | 2 +- .../List/__test__/List.test.tsx | 28 +--- .../List/__test__/props.json | 7 - .../public/components/app/Home/Home.test.tsx | 2 +- .../app/Main/UpdateBreadcrumbs.test.tsx | 8 +- .../AlertingFlyout/index.tsx | 30 ++++ .../AlertIntegrations/index.tsx | 146 +++++++++++++++++ .../components/app/ServiceDetails/index.tsx | 20 +++ .../app/ServiceMap/Cytoscape.stories.tsx | 1 + .../app/ServiceMap/EmptyBanner.test.tsx | 2 +- .../components/app/ServiceMap/index.test.tsx | 2 +- .../app/ServiceNodeMetrics/index.test.tsx | 2 +- .../__test__/ServiceOverview.test.tsx | 4 +- .../app/Settings/ApmIndices/index.test.tsx | 2 +- .../CustomizeUI/CustomLink/index.test.tsx | 2 +- .../app/TraceLink/__test__/TraceLink.test.tsx | 2 +- .../__jest__/TransactionOverview.test.tsx | 2 +- .../ErrorRateAlertTrigger/index.stories.tsx | 26 +++ .../shared/ErrorRateAlertTrigger/index.tsx | 84 ++++++++++ .../__test__/ErrorMetadata.test.tsx | 4 +- .../__test__/SpanMetadata.test.tsx | 4 +- .../__test__/TransactionMetadata.test.tsx | 4 +- .../__test__/MetadataTable.test.tsx | 6 +- .../PopoverExpression/index.tsx | 39 +++++ .../shared/ServiceAlertTrigger/index.tsx | 61 +++++++ .../__test__/TransactionActionMenu.test.tsx | 2 +- .../index.stories.tsx | 50 ++++++ .../TransactionDurationAlertTrigger/index.tsx | 149 ++++++++++++++++++ .../BrowserLineChart.test.tsx | 2 +- .../ApmPluginContext/MockApmPluginContext.tsx | 63 ++++++++ .../index.tsx} | 2 +- .../MockUrlParamsContextProvider.tsx | 40 +++++ .../hooks/useFetcher.integration.test.tsx | 3 +- .../apm/public/hooks/useFetcher.test.tsx | 3 +- .../apm/public/new-platform/plugin.tsx | 76 +++++++-- .../plugins/apm/public/utils/testHelpers.tsx | 63 +------- x-pack/plugins/apm/common/alert_types.ts | 67 ++++++++ x-pack/plugins/apm/kibana.json | 2 +- .../server/lib/alerts/register_apm_alerts.ts | 29 ++++ .../alerts/register_error_rate_alert_type.ts | 108 +++++++++++++ ...egister_transaction_duration_alert_type.ts | 140 ++++++++++++++++ x-pack/plugins/apm/server/plugin.ts | 17 +- .../public/application/lib/capabilities.ts | 47 ++---- .../triggers_actions_ui/public/index.ts | 4 + 45 files changed, 1198 insertions(+), 181 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx rename x-pack/legacy/plugins/apm/public/context/{ApmPluginContext.tsx => ApmPluginContext/index.tsx} (89%) create mode 100644 x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx create mode 100644 x-pack/plugins/apm/common/alert_types.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 6f238b48d9465..6cfd18d0c1cba 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -91,24 +91,34 @@ export const apm: LegacyPluginInitializer = kibana => { navLinkId: 'apm', app: ['apm', 'kibana'], catalogue: ['apm'], + // see x-pack/plugins/features/common/feature_kibana_privileges.ts privileges: { all: { - api: ['apm', 'apm_write'], + api: ['apm', 'apm_write', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { - all: [], + all: ['action', 'action_task_params'], read: [] }, - ui: ['show', 'save'] + ui: [ + 'show', + 'save', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete' + ] }, read: { - api: ['apm'], + api: ['apm', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { - all: [], + all: ['action', 'action_task_params'], read: [] }, - ui: ['show'] + ui: ['show', 'alerting:show', 'actions:show'] } } }); diff --git a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx index c2c396d5b8951..68acaee4abe5d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx @@ -11,9 +11,9 @@ import { APMIndicesPermission } from '../'; import * as hooks from '../../../../hooks/useFetcher'; import { expectTextsInDocument, - MockApmPluginContextWrapper, expectTextsNotInDocument } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('APMIndicesPermission', () => { it('returns empty component when api status is loading', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx index 68d19a41f33a4..a09482d663f65 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/List.test.tsx @@ -9,25 +9,7 @@ import React from 'react'; import { mockMoment, toJson } from '../../../../../utils/testHelpers'; import { ErrorGroupList } from '../index'; import props from './props.json'; -import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; -import { - useUiFilters, - UrlParamsContext -} from '../../../../../context/UrlParamsContext'; - -const mockRefreshTimeRange = jest.fn(); -const MockUrlParamsProvider: React.FC<{ - params?: IUrlParams; -}> = ({ params = props.urlParams, children }) => ( - -); +import { MockUrlParamsContextProvider } from '../../../../../context/UrlParamsContext/MockUrlParamsContextProvider'; describe('ErrorGroupOverview -> List', () => { beforeAll(() => { @@ -37,9 +19,9 @@ describe('ErrorGroupOverview -> List', () => { it('should render empty state', () => { const storeState = {}; const wrapper = mount( - + - , + , storeState ); @@ -48,9 +30,9 @@ describe('ErrorGroupOverview -> List', () => { it('should render with data', () => { const wrapper = mount( - + - + ); expect(toJson(wrapper)).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json index 92198220628d1..431a6c71b103b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/props.json @@ -1,11 +1,4 @@ { - "urlParams": { - "page": 0, - "serviceName": "opbeans-python", - "transactionType": "request", - "start": "2018-01-10T09:51:41.050Z", - "end": "2018-01-10T10:06:41.050Z" - }, "items": [ { "message": "About to blow up!", diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx index 711290942cea1..ab4ca1dfbb49d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Home/Home.test.tsx @@ -7,7 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { Home } from '../Home'; -import { MockApmPluginContextWrapper } from '../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; describe('Home component', () => { it('should render services', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx index 5bf8cb8271fa4..e610f3b84899b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx @@ -8,12 +8,12 @@ import { mount } from 'enzyme'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; -import { - mockApmPluginContextValue, - MockApmPluginContextWrapper -} from '../../../utils/testHelpers'; import { routes } from './route_config'; import { UpdateBreadcrumbs } from './UpdateBreadcrumbs'; +import { + MockApmPluginContextWrapper, + mockApmPluginContextValue +} from '../../../context/ApmPluginContext/MockApmPluginContext'; const setBreadcrumbs = jest.fn(); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx new file mode 100644 index 0000000000000..7e8d057a7be6c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/AlertingFlyout/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { AlertType } from '../../../../../../../../../plugins/apm/common/alert_types'; +import { AlertAdd } from '../../../../../../../../../plugins/triggers_actions_ui/public'; + +type AlertAddProps = React.ComponentProps; + +interface Props { + addFlyoutVisible: AlertAddProps['addFlyoutVisible']; + setAddFlyoutVisibility: AlertAddProps['setAddFlyoutVisibility']; + alertType: AlertType | null; +} + +export function AlertingFlyout(props: Props) { + const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; + + return alertType ? ( + + ) : null; +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx new file mode 100644 index 0000000000000..92b325ab00d35 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiButtonEmpty, + EuiContextMenu, + EuiPopover, + EuiContextMenuPanelDescriptor +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { AlertType } from '../../../../../../../../plugins/apm/common/alert_types'; +import { AlertingFlyout } from './AlertingFlyout'; +import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; + +const alertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.alerts', + { + defaultMessage: 'Alerts' + } +); + +const createThresholdAlertLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.createThresholdAlert', + { + defaultMessage: 'Create threshold alert' + } +); + +const CREATE_THRESHOLD_ALERT_PANEL_ID = 'create_threshold'; + +interface Props { + canReadAlerts: boolean; + canSaveAlerts: boolean; +} + +export function AlertIntegrations(props: Props) { + const { canSaveAlerts, canReadAlerts } = props; + + const plugin = useApmPluginContext(); + + const [popoverOpen, setPopoverOpen] = useState(false); + + const [alertType, setAlertType] = useState(null); + + const button = ( + setPopoverOpen(true)} + > + {i18n.translate('xpack.apm.serviceDetails.alertsMenu.alerts', { + defaultMessage: 'Alerts' + })} + + ); + + const panels: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + title: alertLabel, + items: [ + ...(canSaveAlerts + ? [ + { + name: createThresholdAlertLabel, + panel: CREATE_THRESHOLD_ALERT_PANEL_ID, + icon: 'bell' + } + ] + : []), + ...(canReadAlerts + ? [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts', + { + defaultMessage: 'View active alerts' + } + ), + href: plugin.core.http.basePath.prepend( + '/app/kibana#/management/kibana/triggersActions/alerts' + ), + icon: 'tableOfContents' + } + ] + : []) + ] + }, + { + id: CREATE_THRESHOLD_ALERT_PANEL_ID, + title: createThresholdAlertLabel, + items: [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.transactionDuration', + { + defaultMessage: 'Transaction duration' + } + ), + onClick: () => { + setAlertType(AlertType.TransactionDuration); + } + }, + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.errorRate', + { + defaultMessage: 'Error rate' + } + ), + onClick: () => { + setAlertType(AlertType.ErrorRate); + } + } + ] + } + ]; + + return ( + <> + setPopoverOpen(false)} + panelPaddingSize="none" + anchorPosition="downRight" + > + + + { + if (!visible) { + setAlertType(null); + } + }} + /> + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx index ac7dfd49d4f3d..77ae67b71e1b6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/index.tsx @@ -10,15 +10,27 @@ import { ApmHeader } from '../../shared/ApmHeader'; import { ServiceDetailTabs } from './ServiceDetailTabs'; import { ServiceIntegrations } from './ServiceIntegrations'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { AlertIntegrations } from './AlertIntegrations'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; interface Props { tab: React.ComponentProps['tab']; } export function ServiceDetails({ tab }: Props) { + const plugin = useApmPluginContext(); const { urlParams } = useUrlParams(); const { serviceName } = urlParams; + const canReadAlerts = !!plugin.core.application.capabilities.apm[ + 'alerting:show' + ]; + const canSaveAlerts = !!plugin.core.application.capabilities.apm[ + 'alerting:save' + ]; + + const isAlertingAvailable = canReadAlerts || canSaveAlerts; + return (

@@ -31,6 +43,14 @@ export function ServiceDetails({ tab }: Props) { + {isAlertingAvailable && ( + + + + )} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 7a066b520cc3b..46754c8c7cb6b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -194,6 +194,7 @@ storiesOf('app/ServiceMap/Cytoscape', module) const height = 640; const width = 1340; const serviceName = undefined; // global service map + return ( { describe('render', () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index 241f272b54a1d..b286d33ca74e9 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -11,11 +11,11 @@ import * as urlParamsHooks from '../../../../hooks/useUrlParams'; import * as useLocalUIFilters from '../../../../hooks/useLocalUIFilters'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { SessionStorageMock } from '../../../../services/__test__/SessionStorageMock'; +import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; import { MockApmPluginContextWrapper, mockApmPluginContextValue -} from '../../../../utils/testHelpers'; -import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; +} from '../../../../context/ApmPluginContext/MockApmPluginContext'; jest.mock('ui/new_platform'); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx index fd71bf9709ce9..272c4b3add415 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/ApmIndices/index.test.tsx @@ -7,8 +7,8 @@ import { render, wait } from '@testing-library/react'; import React from 'react'; import { ApmIndices } from '.'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; import * as hooks from '../../../../hooks/useFetcher'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('ApmIndices', () => { it('should not get stuck in infinite loop', async () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index 7c39356189891..b5bee5a5a1ebb 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -13,11 +13,11 @@ import * as hooks from '../../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../../context/LicenseContext'; import { CustomLinkOverview } from '.'; import { - MockApmPluginContextWrapper, expectTextsInDocument, expectTextsNotInDocument } from '../../../../../utils/testHelpers'; import * as saveCustomLink from './CustomLinkFlyout/saveCustomLink'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const data = [ { diff --git a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx index fe58fc39c6cfa..b8d6d9818eb2c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TraceLink/__test__/TraceLink.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { TraceLink } from '../'; import * as hooks from '../../../../hooks/useFetcher'; import * as urlParamsHooks from '../../../../hooks/useUrlParams'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx index 882682f1f6760..22cbeee5c6b7c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.tsx @@ -22,7 +22,7 @@ import * as useFetcherHook from '../../../../hooks/useFetcher'; import { fromQuery } from '../../../shared/Links/url_helpers'; import { Router } from 'react-router-dom'; import { UrlParamsProvider } from '../../../../context/UrlParamsContext'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; jest.spyOn(history, 'push'); jest.spyOn(history, 'replace'); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx new file mode 100644 index 0000000000000..4ef8de7c2b208 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { ErrorRateAlertTrigger } from '.'; + +storiesOf('app/ErrorRateAlertTrigger', module).add('example', props => { + const params = { + threshold: 2, + window: '5m' + }; + + return ( +
+ undefined} + setAlertProperty={() => undefined} + /> +
+ ); +}); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx new file mode 100644 index 0000000000000..6d0a2b96092a1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiFieldNumber } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { ALERT_TYPES_CONFIG } from '../../../../../../../plugins/apm/common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +export interface ErrorRateAlertTriggerParams { + windowSize: number; + windowUnit: string; + threshold: number; +} + +interface Props { + alertParams: ErrorRateAlertTriggerParams; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function ErrorRateAlertTrigger(props: Props) { + const { setAlertParams, setAlertProperty, alertParams } = props; + + const defaults = { + threshold: 25, + windowSize: 1, + windowUnit: 'm' + }; + + const params = { + ...defaults, + ...alertParams + }; + + const fields = [ + + + setAlertParams('threshold', parseInt(e.target.value, 10)) + } + compressed + append={i18n.translate('xpack.apm.errorRateAlertTrigger.errors', { + defaultMessage: 'errors' + })} + /> + , + + setAlertParams('windowSize', windowSize) + } + onChangeWindowUnit={windowUnit => + setAlertParams('windowUnit', windowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx index 0c60d523b8f3f..258788252379a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/ErrorMetadata/__test__/ErrorMetadata.test.tsx @@ -10,9 +10,9 @@ import { render } from '@testing-library/react'; import { APMError } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/apm_error'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx index ee66636d88ba9..0059b7b8fb4b3 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/SpanMetadata/__test__/SpanMetadata.test.tsx @@ -10,9 +10,9 @@ import { SpanMetadata } from '..'; import { Span } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/span'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx index f426074fbef80..3d78f36db9786 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/TransactionMetadata/__test__/TransactionMetadata.test.tsx @@ -10,9 +10,9 @@ import { render } from '@testing-library/react'; import { Transaction } from '../../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import { expectTextsInDocument, - expectTextsNotInDocument, - MockApmPluginContextWrapper + expectTextsNotInDocument } from '../../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx index 979b9118a7534..96202525c8661 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/MetadataTable/__test__/MetadataTable.test.tsx @@ -7,11 +7,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import { MetadataTable } from '..'; -import { - expectTextsInDocument, - MockApmPluginContextWrapper -} from '../../../../utils/testHelpers'; +import { expectTextsInDocument } from '../../../../utils/testHelpers'; import { SectionsWithRows } from '../helper'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderOptions = { wrapper: MockApmPluginContextWrapper diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx new file mode 100644 index 0000000000000..1abdb94c8313e --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/PopoverExpression/index.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiExpression, EuiPopover } from '@elastic/eui'; + +interface Props { + title: string; + value: string; + children?: React.ReactNode; +} + +export const PopoverExpression = (props: Props) => { + const { title, value, children } = props; + + const [popoverOpen, setPopoverOpen] = useState(false); + + return ( + setPopoverOpen(false)} + button={ + setPopoverOpen(true)} + /> + } + > + {children} + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx new file mode 100644 index 0000000000000..98391b277caf6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/ServiceAlertTrigger/index.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; +import { EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; +import { useUrlParams } from '../../../hooks/useUrlParams'; + +interface Props { + alertTypeName: string; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; + defaults: Record; + fields: React.ReactNode[]; +} + +export function ServiceAlertTrigger(props: Props) { + const { urlParams } = useUrlParams(); + + const { + fields, + setAlertParams, + setAlertProperty, + alertTypeName, + defaults + } = props; + + const params: Record = { + ...defaults, + serviceName: urlParams.serviceName! + }; + + useEffect(() => { + // we only want to run this on mount to set default values + setAlertProperty('name', `${alertTypeName} | ${params.serviceName}`); + setAlertProperty('tags', [ + 'apm', + `service.name:${params.serviceName}`.toLowerCase() + ]); + Object.keys(params).forEach(key => { + setAlertParams(key, params[key]); + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + <> + + + {fields.map((field, index) => ( + + {field} + + ))} + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index 9094662e34914..560884aec554a 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -10,13 +10,13 @@ import { TransactionActionMenu } from '../TransactionActionMenu'; import { Transaction } from '../../../../../../../../plugins/apm/typings/es_schemas/ui/transaction'; import * as Transactions from './mockData'; import { - MockApmPluginContextWrapper, expectTextsNotInDocument, expectTextsInDocument } from '../../../../utils/testHelpers'; import * as hooks from '../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../context/LicenseContext'; import { License } from '../../../../../../../../plugins/licensing/common/license'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const renderTransaction = async (transaction: Record) => { const rendered = render( diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx new file mode 100644 index 0000000000000..a8f834103e6c1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { cloneDeep, merge } from 'lodash'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { TransactionDurationAlertTrigger } from '.'; +import { + MockApmPluginContextWrapper, + mockApmPluginContextValue +} from '../../../context/ApmPluginContext/MockApmPluginContext'; +import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/MockUrlParamsContextProvider'; +import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; + +storiesOf('app/TransactionDurationAlertTrigger', module).add( + 'example', + context => { + const params = { + threshold: 1500, + aggregationType: 'avg' as const, + window: '5m' + }; + + const contextMock = (merge(cloneDeep(mockApmPluginContextValue), { + core: { + http: { + get: () => { + return Promise.resolve({ transactionTypes: ['request'] }); + } + } + } + }) as unknown) as ApmPluginContextValue; + + return ( +
+ + + undefined} + setAlertProperty={() => undefined} + /> + + +
+ ); + } +); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx new file mode 100644 index 0000000000000..cdc7c30089b4f --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { map } from 'lodash'; +import { EuiFieldNumber, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ForLastExpression } from '../../../../../../../plugins/triggers_actions_ui/public'; +import { + TRANSACTION_ALERT_AGGREGATION_TYPES, + ALERT_TYPES_CONFIG +} from '../../../../../../../plugins/apm/common/alert_types'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; + +interface Params { + windowSize: number; + windowUnit: string; + threshold: number; + aggregationType: 'avg' | '95th' | '99th'; + serviceName: string; + transactionType: string; +} + +interface Props { + alertParams: Params; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function TransactionDurationAlertTrigger(props: Props) { + const { setAlertParams, alertParams, setAlertProperty } = props; + + const { urlParams } = useUrlParams(); + + const transactionTypes = useServiceTransactionTypes(urlParams); + + if (!transactionTypes.length) { + return null; + } + + const defaults = { + threshold: 1500, + aggregationType: 'avg', + windowSize: 5, + windowUnit: 'm', + transactionType: transactionTypes[0] + }; + + const params = { + ...defaults, + ...alertParams + }; + + const fields = [ + + { + return { + text: key, + value: key + }; + })} + onChange={e => + setAlertParams( + 'transactionType', + e.target.value as Params['transactionType'] + ) + } + compressed + /> + , + + { + return { + text: label, + value: key + }; + })} + onChange={e => + setAlertParams( + 'aggregationType', + e.target.value as Params['aggregationType'] + ) + } + compressed + /> + , + + setAlertParams('threshold', e.target.value)} + append={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { + defaultMessage: 'ms' + })} + compressed + /> + , + + setAlertParams('windowSize', timeWindowSize) + } + onChangeWindowUnit={timeWindowUnit => + setAlertParams('windowUnit', timeWindowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [] + }} + /> + ]; + + return ( + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx index 6d3e29ec09985..9f112475a4a78 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { BrowserLineChart } from './BrowserLineChart'; -import { MockApmPluginContextWrapper } from '../../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; describe('BrowserLineChart', () => { describe('render', () => { diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx new file mode 100644 index 0000000000000..8775dc98c3e1a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ApmPluginContext, ApmPluginContextValue } from '.'; +import { createCallApmApi } from '../../services/rest/createCallApmApi'; +import { ConfigSchema } from '../../new-platform/plugin'; + +const mockCore = { + chrome: { + setBreadcrumbs: () => {} + }, + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + }, + notifications: { + toasts: { + addWarning: () => {}, + addDanger: () => {} + } + } +}; + +const mockConfig: ConfigSchema = { + indexPatternTitle: 'apm-*', + serviceMapEnabled: true, + ui: { + enabled: false + } +}; + +export const mockApmPluginContextValue = { + config: mockConfig, + core: mockCore, + packageInfo: { version: '0' }, + plugins: {} +}; + +export function MockApmPluginContextWrapper({ + children, + value = {} as ApmPluginContextValue +}: { + children?: React.ReactNode; + value?: ApmPluginContextValue; +}) { + if (value.core?.http) { + createCallApmApi(value.core?.http); + } + return ( + + {children} + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx similarity index 89% rename from x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx rename to x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx index 7a9aaa6dfb920..d8934ba4b0151 100644 --- a/x-pack/legacy/plugins/apm/public/context/ApmPluginContext.tsx +++ b/x-pack/legacy/plugins/apm/public/context/ApmPluginContext/index.tsx @@ -6,7 +6,7 @@ import { createContext } from 'react'; import { AppMountContext, PackageInfo } from 'kibana/public'; -import { ApmPluginSetupDeps, ConfigSchema } from '../new-platform/plugin'; +import { ApmPluginSetupDeps, ConfigSchema } from '../../new-platform/plugin'; export type AppMountContextBasePath = AppMountContext['core']['http']['basePath']; diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx new file mode 100644 index 0000000000000..46f51da49692a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/MockUrlParamsContextProvider.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { IUrlParams } from './types'; +import { UrlParamsContext, useUiFilters } from '.'; + +const defaultUrlParams = { + page: 0, + serviceName: 'opbeans-python', + transactionType: 'request', + start: '2018-01-10T09:51:41.050Z', + end: '2018-01-10T10:06:41.050Z' +}; + +interface Props { + params?: IUrlParams; + children: React.ReactNode; + refreshTimeRange?: (time: any) => void; +} + +export const MockUrlParamsContextProvider = ({ + params, + children, + refreshTimeRange = () => undefined +}: Props) => { + const urlParams = { ...defaultUrlParams, ...params }; + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx index 8d8716e6e5cd7..8918d992b4f53 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.integration.test.tsx @@ -6,8 +6,9 @@ import { render, wait } from '@testing-library/react'; import React from 'react'; -import { delay, MockApmPluginContextWrapper } from '../utils/testHelpers'; +import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; const wrapper = MockApmPluginContextWrapper; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx index e3ef1d44c8b03..deb805c542b1e 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.test.tsx @@ -5,8 +5,9 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { delay, MockApmPluginContextWrapper } from '../utils/testHelpers'; +import { delay } from '../utils/testHelpers'; import { useFetcher } from './useFetcher'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; // Wrap the hook with a provider so it can useApmPluginContext const wrapper = MockApmPluginContextWrapper; diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index 0103dd72a3fea..f95767492d85b 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -10,6 +10,8 @@ import { Route, Router, Switch } from 'react-router-dom'; import { ApmRoute } from '@elastic/apm-rum-react'; import styled from 'styled-components'; import { metadata } from 'ui/metadata'; +import { i18n } from '@kbn/i18n'; +import { AlertType } from '../../../../../plugins/apm/common/alert_types'; import { CoreSetup, CoreStart, @@ -39,6 +41,12 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { APMIndicesPermission } from '../components/app/APMIndicesPermission'; +import { + TriggersAndActionsUIPublicPluginSetup, + AlertsContextProvider +} from '../../../../../plugins/triggers_actions_ui/public'; +import { ErrorRateAlertTrigger } from '../components/shared/ErrorRateAlertTrigger'; +import { TransactionDurationAlertTrigger } from '../components/shared/TransactionDurationAlertTrigger'; import { createCallApmApi } from '../services/rest/createCallApmApi'; export const REACT_APP_ROOT_ID = 'react-apm-root'; @@ -72,6 +80,7 @@ export interface ApmPluginSetupDeps { data: DataPublicPluginSetup; home: HomePublicPluginSetup; licensing: LicensingPluginSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } export interface ConfigSchema { @@ -135,25 +144,58 @@ export class ApmPlugin plugins }; + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.ErrorRate, + name: i18n.translate('xpack.apm.alertTypes.errorRate', { + defaultMessage: 'Error rate' + }), + iconClass: 'bell', + alertParamsExpression: ErrorRateAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + + plugins.triggers_actions_ui.alertTypeRegistry.register({ + id: AlertType.TransactionDuration, + name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { + defaultMessage: 'Transaction duration' + }), + iconClass: 'bell', + alertParamsExpression: TransactionDurationAlertTrigger, + validate: () => ({ + errors: [] + }) + }); + ReactDOM.render( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + , document.getElementById(REACT_APP_ROOT_ID) ); diff --git a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx index 6bcfbc4541b64..36c0e18777bfd 100644 --- a/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/legacy/plugins/apm/public/utils/testHelpers.tsx @@ -11,7 +11,7 @@ import enzymeToJson from 'enzyme-to-json'; import { Location } from 'history'; import moment from 'moment'; import { Moment } from 'moment-timezone'; -import React, { ReactNode } from 'react'; +import React from 'react'; import { render, waitForElement } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { MemoryRouter } from 'react-router-dom'; @@ -24,12 +24,7 @@ import { ESSearchResponse, ESSearchRequest } from '../../../../../plugins/apm/typings/elasticsearch'; -import { - ApmPluginContext, - ApmPluginContextValue -} from '../context/ApmPluginContext'; -import { ConfigSchema } from '../new-platform/plugin'; -import { createCallApmApi } from '../services/rest/createCallApmApi'; +import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext'; export function toJson(wrapper: ReactWrapper) { return enzymeToJson(wrapper, { @@ -186,57 +181,3 @@ export async function inspectSearchParams( } export type SearchParamsMock = PromiseReturnType; - -const mockCore = { - chrome: { - setBreadcrumbs: () => {} - }, - http: { - basePath: { - prepend: (path: string) => `/basepath${path}` - } - }, - notifications: { - toasts: { - addWarning: () => {}, - addDanger: () => {} - } - } -}; - -const mockConfig: ConfigSchema = { - indexPatternTitle: 'apm-*', - serviceMapEnabled: true, - ui: { - enabled: false - } -}; - -export const mockApmPluginContextValue = { - config: mockConfig, - core: mockCore, - packageInfo: { version: '0' }, - plugins: {} -}; - -export function MockApmPluginContextWrapper({ - children, - value = {} as ApmPluginContextValue -}: { - children?: ReactNode; - value?: ApmPluginContextValue; -}) { - if (value.core?.http) { - createCallApmApi(value.core?.http); - } - return ( - - {children} - - ); -} diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts new file mode 100644 index 0000000000000..51e1f88512965 --- /dev/null +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export enum AlertType { + ErrorRate = 'apm.error_rate', + TransactionDuration = 'apm.transaction_duration' +} + +export const ALERT_TYPES_CONFIG = { + [AlertType.ErrorRate]: { + name: i18n.translate('xpack.apm.errorRateAlert.name', { + defaultMessage: 'Error rate threshold' + }), + actionGroups: [ + { + id: 'threshold_met', + name: i18n.translate('xpack.apm.errorRateAlert.thresholdMet', { + defaultMessage: 'Threshold met' + }) + } + ], + defaultActionGroupId: 'threshold_met' + }, + [AlertType.TransactionDuration]: { + name: i18n.translate('xpack.apm.transactionDurationAlert.name', { + defaultMessage: 'Transaction duration threshold' + }), + actionGroups: [ + { + id: 'threshold_met', + name: i18n.translate( + 'xpack.apm.transactionDurationAlert.thresholdMet', + { + defaultMessage: 'Threshold met' + } + ) + } + ], + defaultActionGroupId: 'threshold_met' + } +}; + +export const TRANSACTION_ALERT_AGGREGATION_TYPES = { + avg: i18n.translate( + 'xpack.apm.transactionDurationAlert.aggregationType.avg', + { + defaultMessage: 'Average' + } + ), + '95th': i18n.translate( + 'xpack.apm.transactionDurationAlert.aggregationType.95th', + { + defaultMessage: '95th percentile' + } + ), + '99th': i18n.translate( + 'xpack.apm.transactionDurationAlert.aggregationType.99th', + { + defaultMessage: '99th percentile' + } + ) +}; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 96579377c95e8..931fd92e1ecc3 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -6,5 +6,5 @@ "configPath": ["xpack", "apm"], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection"] + "optionalPlugins": ["cloud", "usageCollection", "taskManager","actions", "alerting"] } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts new file mode 100644 index 0000000000000..cb3dd761040da --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { AlertingPlugin } from '../../../../alerting/server'; +import { ActionsPlugin } from '../../../../actions/server'; +import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; +import { registerErrorRateAlertType } from './register_error_rate_alert_type'; +import { APMConfig } from '../..'; + +interface Params { + alerting: AlertingPlugin['setup']; + actions: ActionsPlugin['setup']; + config$: Observable; +} + +export function registerApmAlerts(params: Params) { + registerTransactionDurationAlertType({ + alerting: params.alerting, + config$: params.config$ + }); + registerErrorRateAlertType({ + alerting: params.alerting, + config$: params.config$ + }); +} diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts new file mode 100644 index 0000000000000..187a75d0b61f2 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { + ESSearchResponse, + ESSearchRequest +} from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME +} from '../../../common/elasticsearch_fieldnames'; +import { AlertingPlugin } from '../../../../alerting/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; +import { APMConfig } from '../..'; + +interface RegisterAlertParams { + alerting: AlertingPlugin['setup']; + config$: Observable; +} + +const paramsSchema = schema.object({ + serviceName: schema.string(), + windowSize: schema.number(), + windowUnit: schema.string(), + threshold: schema.number() +}); + +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.ErrorRate]; + +export function registerErrorRateAlertType({ + alerting, + config$ +}: RegisterAlertParams) { + alerting.registerType({ + id: AlertType.ErrorRate, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema + }, + + executor: async ({ services, params }) => { + const config = await config$.pipe(take(1)).toPromise(); + + const alertParams = params as TypeOf; + + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient + }); + + const searchParams = { + index: indices['apm_oss.errorIndices'], + size: 0, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}` + } + } + }, + { + term: { + [PROCESSOR_EVENT]: 'error' + } + }, + { + term: { + [SERVICE_NAME]: alertParams.serviceName + } + } + ] + } + }, + track_total_hits: true + } + }; + + const response: ESSearchResponse< + unknown, + ESSearchRequest + > = await services.callCluster('search', searchParams); + + const value = response.hits.total.value; + + if (value && value > alertParams.threshold) { + const alertInstance = services.alertInstanceFactory( + AlertType.ErrorRate + ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId); + } + + return {}; + } + }); +} diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts new file mode 100644 index 0000000000000..7575a8268bc26 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { ESSearchResponse } from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_TYPE, + TRANSACTION_DURATION +} from '../../../common/elasticsearch_fieldnames'; +import { AlertingPlugin } from '../../../../alerting/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; +import { APMConfig } from '../..'; + +interface RegisterAlertParams { + alerting: AlertingPlugin['setup']; + config$: Observable; +} + +const paramsSchema = schema.object({ + serviceName: schema.string(), + transactionType: schema.string(), + windowSize: schema.number(), + windowUnit: schema.string(), + threshold: schema.number(), + aggregationType: schema.oneOf([ + schema.literal('avg'), + schema.literal('95th'), + schema.literal('99th') + ]) +}); + +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.TransactionDuration]; + +export function registerTransactionDurationAlertType({ + alerting, + config$ +}: RegisterAlertParams) { + alerting.registerType({ + id: AlertType.TransactionDuration, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema + }, + + executor: async ({ services, params }) => { + const config = await config$.pipe(take(1)).toPromise(); + + const alertParams = params as TypeOf; + + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient + }); + + const searchParams = { + index: indices['apm_oss.transactionIndices'], + size: 0, + body: { + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}` + } + } + }, + { + term: { + [PROCESSOR_EVENT]: 'transaction' + } + }, + { + term: { + [SERVICE_NAME]: alertParams.serviceName + } + }, + { + term: { + [TRANSACTION_TYPE]: alertParams.transactionType + } + } + ] + } + }, + aggs: { + agg: + alertParams.aggregationType === 'avg' + ? { + avg: { + field: TRANSACTION_DURATION + } + } + : { + percentiles: { + field: TRANSACTION_DURATION, + percents: [ + alertParams.aggregationType === '95th' ? 95 : 99 + ] + } + } + } + } + }; + + const response: ESSearchResponse< + unknown, + typeof searchParams + > = await services.callCluster('search', searchParams); + + if (!response.aggregations) { + return; + } + + const { agg } = response.aggregations; + + const value = 'values' in agg ? agg.values[0] : agg.value; + + if (value && value > alertParams.threshold * 1000) { + const alertInstance = services.alertInstanceFactory( + AlertType.TransactionDuration + ); + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId); + } + + return {}; + } + }); +} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index db14730f802a9..e140340786e8a 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -8,7 +8,10 @@ import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; import { once } from 'lodash'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { TaskManagerSetupContract } from '../../task_manager/server'; +import { AlertingPlugin } from '../../alerting/server'; +import { ActionsPlugin } from '../../actions/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; @@ -21,6 +24,7 @@ import { tutorialProvider } from './tutorial'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; +import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; export interface LegacySetup { server: Server; @@ -47,6 +51,9 @@ export class APMPlugin implements Plugin { licensing: LicensingPluginSetup; cloud?: CloudSetup; usageCollection?: UsageCollectionSetup; + taskManager?: TaskManagerSetupContract; + alerting?: AlertingPlugin['setup']; + actions?: ActionsPlugin['setup']; } ) { const logger = this.initContext.logger.get('apm'); @@ -55,6 +62,14 @@ export class APMPlugin implements Plugin { map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) ); + if (plugins.actions && plugins.alerting) { + registerApmAlerts({ + alerting: plugins.alerting, + actions: plugins.actions, + config$: mergedConfig$ + }); + } + this.legacySetup$.subscribe(__LEGACY => { createApmApi().init(core, { config$: mergedConfig$, logger, __LEGACY }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts index e5693e31c2d66..f8102189c425c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts @@ -10,44 +10,21 @@ * will possibly go away with https://github.com/elastic/kibana/issues/52300. */ -export function hasShowAlertsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['alerting:show']) { - return true; - } - return false; -} +type Capabilities = Record; -export function hasShowActionsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['actions:show']) { - return true; - } - return false; -} +const apps = ['apm', 'siem']; -export function hasSaveAlertsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['alerting:save']) { - return true; - } - return false; +function hasCapability(capabilities: Capabilities, capability: string) { + return apps.some(app => capabilities[app]?.[capability]); } -export function hasSaveActionsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['actions:save']) { - return true; - } - return false; +function createCapabilityCheck(capability: string) { + return (capabilities: Capabilities) => hasCapability(capabilities, capability); } -export function hasDeleteAlertsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['alerting:delete']) { - return true; - } - return false; -} - -export function hasDeleteActionsCapability(capabilities: any): boolean { - if (capabilities.siem && capabilities.siem['actions:delete']) { - return true; - } - return false; -} +export const hasShowAlertsCapability = createCapabilityCheck('alerting:show'); +export const hasShowActionsCapability = createCapabilityCheck('actions:show'); +export const hasSaveAlertsCapability = createCapabilityCheck('alerting:save'); +export const hasSaveActionsCapability = createCapabilityCheck('actions:save'); +export const hasDeleteAlertsCapability = createCapabilityCheck('alerting:delete'); +export const hasDeleteActionsCapability = createCapabilityCheck('actions:delete'); diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 342401c4778d8..96645e856e418 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -23,3 +23,7 @@ export function plugin(ctx: PluginInitializerContext) { export { Plugin }; export * from './plugin'; + +export { TIME_UNITS } from './application/constants'; +export { getTimeUnitLabel } from './common/lib/get_time_unit_label'; +export { ForLastExpression } from './common/expression_items/for_the_last'; From 70625429199afc6f1b30a30d9630818905085161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 24 Mar 2020 10:20:51 +0000 Subject: [PATCH 23/64] [skip-ci] Fix CODEOWNERS paths for the Pulse team (#60944) --- .github/CODEOWNERS | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d48b29c89ece6..6519bf9c493f9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -150,8 +150,11 @@ # Pulse /packages/kbn-analytics/ @elastic/pulse /src/legacy/core_plugins/ui_metric/ @elastic/pulse +/src/plugins/telemetry/ @elastic/pulse +/src/plugins/telemetry_collection_manager/ @elastic/pulse +/src/plugins/telemetry_management_section/ @elastic/pulse /src/plugins/usage_collection/ @elastic/pulse -/x-pack/legacy/plugins/telemetry/ @elastic/pulse +/x-pack/plugins/telemetry_collection_xpack/ @elastic/pulse # Kibana Alerting Services /x-pack/legacy/plugins/alerting/ @elastic/kibana-alerting-services From 4dbcb3c0e99553af4306670ac4a980aad6db209b Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Tue, 24 Mar 2020 10:52:00 +0000 Subject: [PATCH 24/64] [Alerting] removes unimplemented buttons from Alert Details page (#60934) Removed the "Edit" and "View in Activity Log" buttons as they have not yet been implemented. --- .../components/alert_details.test.tsx | 60 +------------------ .../components/alert_details.tsx | 17 ------ 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index f025b0396f04d..d781e8b761845 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -8,16 +8,8 @@ import uuid from 'uuid'; import { shallow } from 'enzyme'; import { AlertDetails } from './alert_details'; import { Alert, ActionType } from '../../../../types'; -import { - EuiTitle, - EuiBadge, - EuiFlexItem, - EuiButtonEmpty, - EuiSwitch, - EuiBetaBadge, -} from '@elastic/eui'; +import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiBetaBadge } from '@elastic/eui'; import { times, random } from 'lodash'; -import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { ViewInApp } from './view_in_app'; @@ -218,31 +210,6 @@ describe('alert_details', () => { }); describe('links', () => { - it('links to the Edit flyout', () => { - const alert = mockAlert(); - - const alertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, - defaultActionGroupId: 'default', - }; - - expect( - shallow( - - ).containsMatchingElement( - - - - ) - ).toBeTruthy(); - }); - it('links to the app that created the alert', () => { const alert = mockAlert(); @@ -260,31 +227,6 @@ describe('alert_details', () => { ).containsMatchingElement() ).toBeTruthy(); }); - - it('links to the activity log', () => { - const alert = mockAlert(); - - const alertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, - defaultActionGroupId: 'default', - }; - - expect( - shallow( - - ).containsMatchingElement( - - - - ) - ).toBeTruthy(); - }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 49e818ebc7ee4..1f55e61e9ee0d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -17,7 +17,6 @@ import { EuiBadge, EuiPage, EuiPageContentBody, - EuiButtonEmpty, EuiSwitch, EuiCallOut, EuiSpacer, @@ -87,25 +86,9 @@ export const AlertDetails: React.FunctionComponent = ({ - - - - - - - - - - From d31e5f524f13f1d8fd91cf1e5a716ecb5c6fb742 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 24 Mar 2020 12:34:43 +0100 Subject: [PATCH 25/64] [Uptime] Ml detection of duration anomalies (#59785) * add flyout * add state * update state * ad job * update * updat * add ml analyze button * update api * use differential colors for duration chart * remove duration chart gql * update type * type fix * fix tyoe * update translation * update test * update conflicts * update anomaly record * chart * added annotations * update error handling * update * update types * fixed types * fix types * update types * update * update * remove unnecessary change * remove unnecessary change * fix type * update * save * update pr * update tets * update job deletion * update * update tets * upadte tests * fix types * update title text * update types * fixed tests * update tests and types * updated types * fix PR feedback * unit test * update more types * update test and manage job * resolve conflicts * types * remove unnecessary change * revert ml code * revert ml code * fixed formatting issues pointed by pr feedback --- src/plugins/kibana_react/public/index.ts | 1 + .../uptime/common/constants/rest_api.ts | 6 + .../plugins/uptime/common/constants/ui.ts | 4 + .../connected/charts/monitor_duration.tsx | 63 ++++- .../connected/empty_state/empty_state.tsx | 4 +- .../monitor/status_bar_container.tsx | 25 +- .../duration_charts.test.tsx.snap | 2 + .../charts/__tests__/duration_charts.test.tsx | 4 +- .../functional/charts/annotation_tooltip.tsx | 54 ++++ .../functional/charts/duration_chart.tsx | 71 +++++- .../charts/duration_line_bar_list.tsx | 91 +++++++ .../__snapshots__/empty_state.test.tsx.snap | 30 +-- .../__tests__/empty_state.test.tsx | 17 +- .../functional/empty_state/empty_state.tsx | 3 +- .../empty_state/empty_state_error.tsx | 3 +- .../functional/monitor_list/translations.ts | 7 + .../{ => ping_list}/location_name.tsx | 0 .../functional/ping_list/ping_list.tsx | 2 +- .../confirm_delete.test.tsx.snap | 56 ++++ .../__snapshots__/license_info.test.tsx.snap | 73 ++++++ .../__snapshots__/ml_flyout.test.tsx.snap | 240 ++++++++++++++++++ .../ml_integerations.test.tsx.snap | 86 +++++++ .../__snapshots__/ml_job_link.test.tsx.snap | 82 ++++++ .../__snapshots__/ml_manage_job.test.tsx.snap | 90 +++++++ .../ml/__tests__/confirm_delete.test.tsx | 25 ++ .../ml/__tests__/license_info.test.tsx | 21 ++ .../ml/__tests__/ml_flyout.test.tsx | 113 +++++++++ .../ml/__tests__/ml_integerations.test.tsx | 30 +++ .../ml/__tests__/ml_job_link.test.tsx | 25 ++ .../ml/__tests__/ml_manage_job.test.tsx | 32 +++ .../monitor_details/ml/confirm_delete.tsx | 59 +++++ .../monitor_details/ml/license_info.tsx | 33 +++ .../monitor_details/ml/manage_ml_job.tsx | 80 ++++++ .../monitor_details/ml/ml_flyout.tsx | 86 +++++++ .../ml/ml_flyout_container.tsx | 154 +++++++++++ .../monitor_details/ml/ml_integeration.tsx | 111 ++++++++ .../monitor_details/ml/ml_job_link.tsx | 52 ++++ .../monitor_details/ml/translations.tsx | 150 +++++++++++ .../contexts/uptime_settings_context.tsx | 20 +- .../uptime/public/state/actions/index.ts | 1 + .../public/state/actions/index_status.ts | 2 +- .../uptime/public/state/actions/ml_anomaly.ts | 46 ++++ .../public/state/actions/monitor_duration.ts | 5 +- .../uptime/public/state/actions/types.ts | 30 ++- .../uptime/public/state/actions/utils.ts | 14 +- .../uptime/public/state/api/ml_anomaly.ts | 88 +++++++ ...th_effect.test.ts => fetch_effect.test.ts} | 36 ++- .../public/state/effects/fetch_effect.ts | 8 +- .../uptime/public/state/effects/index.ts | 2 + .../uptime/public/state/effects/ml_anomaly.ts | 53 ++++ .../uptime/public/state/reducers/index.ts | 2 + .../public/state/reducers/index_status.ts | 14 +- .../public/state/reducers/ml_anomaly.ts | 71 ++++++ .../public/state/reducers/monitor_duration.ts | 6 +- .../uptime/public/state/reducers/types.ts | 7 +- .../uptime/public/state/reducers/utils.ts | 33 ++- .../state/selectors/__tests__/index.test.ts | 22 +- .../uptime/public/state/selectors/index.ts | 41 ++- x-pack/package.json | 2 +- x-pack/plugins/ml/common/types/anomalies.ts | 17 ++ .../ml/common/types/data_recognizer.ts | 17 ++ .../models/data_recognizer/data_recognizer.ts | 13 +- .../modules/uptime_heartbeat/logo.json | 3 + .../modules/uptime_heartbeat/manifest.json | 26 ++ .../ml/datafeed_high_latency_by_geo.json | 13 + .../ml/high_latency_by_geo.json | 29 +++ .../build_anomaly_table_items.d.ts | 19 +- .../models/results_service/results_service.ts | 4 +- yarn.lock | 73 +++++- 69 files changed, 2522 insertions(+), 180 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_line_bar_list.tsx rename x-pack/legacy/plugins/uptime/public/components/functional/{ => ping_list}/location_name.tsx (100%) create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_integerations.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_job_link.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_manage_job.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/confirm_delete.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/license_info.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/ml_flyout.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/ml_integerations.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/ml_job_link.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/ml_manage_job.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/confirm_delete.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/license_info.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/manage_ml_job.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_flyout_container.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_integeration.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/ml_job_link.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/translations.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/state/actions/ml_anomaly.ts create mode 100644 x-pack/legacy/plugins/uptime/public/state/api/ml_anomaly.ts rename x-pack/legacy/plugins/uptime/public/state/effects/__tests__/{fecth_effect.test.ts => fetch_effect.test.ts} (67%) create mode 100644 x-pack/legacy/plugins/uptime/public/state/effects/ml_anomaly.ts create mode 100644 x-pack/legacy/plugins/uptime/public/state/reducers/ml_anomaly.ts create mode 100644 x-pack/plugins/ml/common/types/data_recognizer.ts create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/uptime_heartbeat/logo.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/uptime_heartbeat/manifest.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/uptime_heartbeat/ml/datafeed_high_latency_by_geo.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/uptime_heartbeat/ml/high_latency_by_geo.json diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index e88ca7178cde3..e1689e38dbfe0 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -26,6 +26,7 @@ export * from './field_icon'; export * from './table_list_view'; export * from './split_panel'; export { ValidatedDualRange } from './validated_range'; +export * from './notifications'; export { Markdown, MarkdownSimple } from './markdown'; export { reactToUiComponent, uiToReactComponent } from './adapters'; export { useUrlTracker } from './use_url_tracker'; diff --git a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts index 61197d6dc373d..a1a3e86e6a97e 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts @@ -16,4 +16,10 @@ export enum API_URLS { PING_HISTOGRAM = `/api/uptime/ping/histogram`, SNAPSHOT_COUNT = `/api/uptime/snapshot/count`, FILTERS = `/api/uptime/filters`, + + ML_MODULE_JOBS = `/api/ml/modules/jobs_exist/`, + ML_SETUP_MODULE = '/api/ml/modules/setup/', + ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`, + ML_CAPABILITIES = '/api/ml/ml_capabilities', + ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`, } diff --git a/x-pack/legacy/plugins/uptime/common/constants/ui.ts b/x-pack/legacy/plugins/uptime/common/constants/ui.ts index 8d223dbbba556..29e8dabf53f92 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/ui.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/ui.ts @@ -15,6 +15,10 @@ export enum STATUS { DOWN = 'down', } +export const ML_JOB_ID = 'high_latency_by_geo'; + +export const ML_MODULE_ID = 'uptime_heartbeat'; + export const UNNAMED_LOCATION = 'Unnamed-location'; export const SHORT_TS_LOCALE = 'en-short-locale'; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx index 8d2b8d2cd8e0d..7d1cb08cb8b1c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/charts/monitor_duration.tsx @@ -7,10 +7,21 @@ import React, { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useUrlParams } from '../../../hooks'; -import { getMonitorDurationAction } from '../../../state/actions'; +import { + getAnomalyRecordsAction, + getMLCapabilitiesAction, + getMonitorDurationAction, +} from '../../../state/actions'; import { DurationChartComponent } from '../../functional/charts'; -import { selectDurationLines } from '../../../state/selectors'; +import { + anomaliesSelector, + hasMLFeatureAvailable, + hasMLJobSelector, + selectDurationLines, +} from '../../../state/selectors'; import { UptimeRefreshContext } from '../../../contexts'; +import { getMLJobId } from '../../../state/api/ml_anomaly'; +import { JobStat } from '../../../../../../../plugins/ml/common/types/data_recognizer'; interface Props { monitorId: string; @@ -18,24 +29,58 @@ interface Props { export const DurationChart: React.FC = ({ monitorId }: Props) => { const [getUrlParams] = useUrlParams(); - const { dateRangeStart, dateRangeEnd } = getUrlParams(); + const { + dateRangeStart, + dateRangeEnd, + absoluteDateRangeStart, + absoluteDateRangeEnd, + } = getUrlParams(); - const { monitor_duration, loading } = useSelector(selectDurationLines); + const { durationLines, loading } = useSelector(selectDurationLines); + + const isMLAvailable = useSelector(hasMLFeatureAvailable); + + const { data: mlJobs, loading: jobsLoading } = useSelector(hasMLJobSelector); + + const hasMLJob = + !!mlJobs?.jobsExist && + !!mlJobs.jobs.find((job: JobStat) => job.id === getMLJobId(monitorId as string)); + + const anomalies = useSelector(anomaliesSelector); const dispatch = useDispatch(); const { lastRefresh } = useContext(UptimeRefreshContext); useEffect(() => { - dispatch( - getMonitorDurationAction({ monitorId, dateStart: dateRangeStart, dateEnd: dateRangeEnd }) - ); + if (isMLAvailable) { + const anomalyParams = { + listOfMonitorIds: [monitorId], + dateStart: absoluteDateRangeStart, + dateEnd: absoluteDateRangeEnd, + }; + + dispatch(getAnomalyRecordsAction.get(anomalyParams)); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dateRangeStart, dateRangeEnd, dispatch, lastRefresh, monitorId, isMLAvailable]); + + useEffect(() => { + const params = { monitorId, dateStart: dateRangeStart, dateEnd: dateRangeEnd }; + dispatch(getMonitorDurationAction(params)); }, [dateRangeStart, dateRangeEnd, dispatch, lastRefresh, monitorId]); + useEffect(() => { + dispatch(getMLCapabilitiesAction.get()); + }, [dispatch]); + return ( ); }; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx index cac7042ca5b5c..b383a696095a3 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/empty_state/empty_state.tsx @@ -11,7 +11,7 @@ import { indexStatusSelector } from '../../../state/selectors'; import { EmptyStateComponent } from '../../functional/empty_state/empty_state'; export const EmptyState: React.FC = ({ children }) => { - const { data, loading, errors } = useSelector(indexStatusSelector); + const { data, loading, error } = useSelector(indexStatusSelector); const dispatch = useDispatch(); @@ -23,7 +23,7 @@ export const EmptyState: React.FC = ({ children }) => { ); diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx index 456fa2b30bca8..9e7834ae6f242 100644 --- a/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/connected/monitor/status_bar_container.tsx @@ -22,7 +22,8 @@ interface StateProps { } interface DispatchProps { - loadMonitorStatus: (dateStart: string, dateEnd: string, monitorId: string) => void; + loadMonitorStatus: typeof getMonitorStatusAction; + loadSelectedMonitor: typeof getSelectedMonitorAction; } interface OwnProps { @@ -33,6 +34,7 @@ type Props = OwnProps & StateProps & DispatchProps; const Container: React.FC = ({ loadMonitorStatus, + loadSelectedMonitor, monitorId, monitorStatus, monitorLocations, @@ -43,8 +45,9 @@ const Container: React.FC = ({ const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = getUrlParams(); useEffect(() => { - loadMonitorStatus(dateStart, dateEnd, monitorId); - }, [monitorId, dateStart, dateEnd, loadMonitorStatus, lastRefresh]); + loadMonitorStatus({ dateStart, dateEnd, monitorId }); + loadSelectedMonitor({ monitorId }); + }, [monitorId, dateStart, dateEnd, loadMonitorStatus, lastRefresh, loadSelectedMonitor]); return ( ({ }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ - loadMonitorStatus: (dateStart: string, dateEnd: string, monitorId: string) => { - dispatch( - getMonitorStatusAction({ - monitorId, - dateStart, - dateEnd, - }) - ); - dispatch( - getSelectedMonitorAction({ - monitorId, - }) - ); - }, + loadSelectedMonitor: params => dispatch(getSelectedMonitorAction(params)), + loadMonitorStatus: params => dispatch(getMonitorStatusAction(params)), }); // @ts-ignore TODO: Investigate typescript issues here diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap index 1e2d2b9144416..6c38f3e338cfd 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/duration_charts.test.tsx.snap @@ -52,6 +52,8 @@ exports[`MonitorCharts component renders the component without errors 1`] = ` } > { it('renders the component without errors', () => { const component = shallowWithRouter( ); expect(component).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx new file mode 100644 index 0000000000000..ad2a6d02c5364 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/annotation_tooltip.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import moment from 'moment'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; + +const Header = styled.div` + font-weight: bold; + padding-left: 4px; +`; + +const RecordSeverity = styled.div` + font-weight: bold; + border-left: 4px solid ${props => props.color}; + padding-left: 2px; +`; + +const TimeDiv = styled.div` + font-weight: 500; + border-bottom: 1px solid gray; + padding-bottom: 2px; +`; + +export const AnnotationTooltip = ({ details }: { details: string }) => { + const data = JSON.parse(details); + + function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + return ( + <> + {moment(data.time).format('lll')} +
+ +
+ + + + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx index 6bd4e7431f97a..d149e7a6deb5a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Axis, Chart, Position, timeFormatter, Settings } from '@elastic/charts'; -import { EuiPanel, EuiTitle } from '@elastic/eui'; -import React from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; +import { Axis, Chart, Position, timeFormatter, Settings } from '@elastic/charts'; +import { SeriesIdentifier } from '@elastic/charts/dist/chart_types/xy_chart/utils/series'; import { getChartDateLabel } from '../../../lib/helper'; import { LocationDurationLine } from '../../../../common/types'; import { DurationLineSeriesList } from './duration_line_series_list'; @@ -17,6 +18,9 @@ import { ChartWrapper } from './chart_wrapper'; import { useUrlParams } from '../../../hooks'; import { getTickFormat } from './get_tick_format'; import { ChartEmptyState } from './chart_empty_state'; +import { DurationAnomaliesBar } from './duration_line_bar_list'; +import { MLIntegrationComponent } from '../../monitor_details/ml/ml_integeration'; +import { AnomalyRecords } from '../../../state/actions'; interface DurationChartProps { /** @@ -29,6 +33,10 @@ interface DurationChartProps { * To represent the loading spinner on chart */ loading: boolean; + + hasMLJob: boolean; + + anomalies: AnomalyRecords | null; } /** @@ -37,29 +45,64 @@ interface DurationChartProps { * milliseconds. * @param props The props required for this component to render properly */ -export const DurationChartComponent = ({ locationDurationLines, loading }: DurationChartProps) => { +export const DurationChartComponent = ({ + locationDurationLines, + anomalies, + loading, + hasMLJob, +}: DurationChartProps) => { const hasLines = locationDurationLines.length > 0; const [getUrlParams, updateUrlParams] = useUrlParams(); const { absoluteDateRangeStart: min, absoluteDateRangeEnd: max } = getUrlParams(); + const [hiddenLegends, setHiddenLegends] = useState([]); + const onBrushEnd = (minX: number, maxX: number) => { updateUrlParams({ dateRangeStart: moment(minX).toISOString(), dateRangeEnd: moment(maxX).toISOString(), }); }; + + const legendToggleVisibility = (legendItem: SeriesIdentifier | null) => { + if (legendItem) { + setHiddenLegends(prevState => { + if (prevState.includes(legendItem.specId)) { + return [...prevState.filter(item => item !== legendItem.specId)]; + } else { + return [...prevState, legendItem.specId]; + } + }); + } + }; + return ( <> - -

- -

-
+ + + +

+ {hasMLJob ? ( + + ) : ( + + )} +

+
+
+ + + +
+ {hasLines ? ( @@ -69,6 +112,7 @@ export const DurationChartComponent = ({ locationDurationLines, loading }: Durat showLegendExtra legendPosition={Position.Bottom} onBrushEnd={onBrushEnd} + onLegendItemClick={legendToggleVisibility} /> + ) : ( { + const anomalyAnnotations: Map = new Map(); + + Object.keys(ANOMALY_SEVERITY).forEach(severityLevel => { + anomalyAnnotations.set(severityLevel.toLowerCase(), { rect: [], color: '' }); + }); + + if (anomalies?.anomalies) { + const records = anomalies.anomalies; + records.forEach((record: any) => { + let recordObsvLoc = record.source['observer.geo.name']?.[0] ?? 'N/A'; + if (recordObsvLoc === '') { + recordObsvLoc = 'N/A'; + } + if (hiddenLegends.length && hiddenLegends.includes(`loc-avg-${recordObsvLoc}`)) { + return; + } + const severityLevel = getSeverityType(record.severity); + + const tooltipData = { + time: record.source.timestamp, + score: record.severity, + severity: severityLevel, + color: getSeverityColor(record.severity), + }; + + const anomalyRect = { + coordinates: { + x0: moment(record.source.timestamp).valueOf(), + x1: moment(record.source.timestamp) + .add(record.source.bucket_span, 's') + .valueOf(), + }, + details: JSON.stringify(tooltipData), + }; + anomalyAnnotations.get(severityLevel)!.rect.push(anomalyRect); + anomalyAnnotations.get(severityLevel)!.color = getSeverityColor(record.severity); + }); + } + + const getRectStyle = (color: string) => { + return { + fill: color, + opacity: 1, + strokeWidth: 2, + stroke: color, + }; + }; + + const tooltipFormatter: AnnotationTooltipFormatter = (details?: string) => { + return ; + }; + + return ( + <> + {Array.from(anomalyAnnotations).map(([keyIndex, rectAnnotation]) => { + return rectAnnotation.rect.length > 0 ? ( + + ) : null; + })} + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap index 5548189175c55..2d45bbd18a60c 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/__snapshots__/empty_state.test.tsx.snap @@ -749,17 +749,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = ` @@ -904,7 +884,7 @@ exports[`EmptyState component renders error message when an error occurs 1`] = ` body={

- An error occurred + There was an error fetching your data.

} @@ -971,9 +951,9 @@ exports[`EmptyState component renders error message when an error occurs 1`] = ` className="euiText euiText--medium" >

- An error occurred + There was an error fetching your data.

diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx index 20113df3010f8..a74ad543c3318 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/__tests__/empty_state.test.tsx @@ -7,8 +7,9 @@ import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { EmptyStateComponent } from '../empty_state'; -import { GraphQLError } from 'graphql'; import { StatesIndexStatus } from '../../../../../common/runtime_types'; +import { IHttpFetchError } from '../../../../../../../../../target/types/core/public/http'; +import { HttpFetchError } from '../../../../../../../../../src/core/public/http/http_fetch_error'; describe('EmptyState component', () => { let statesIndexStatus: StatesIndexStatus; @@ -41,18 +42,8 @@ describe('EmptyState component', () => { }); it(`renders error message when an error occurs`, () => { - const errors: GraphQLError[] = [ - { - message: 'An error occurred', - locations: undefined, - path: undefined, - nodes: undefined, - source: undefined, - positions: undefined, - originalError: undefined, - extensions: undefined, - name: 'foo', - }, + const errors: IHttpFetchError[] = [ + new HttpFetchError('There was an error fetching your data.', 'error', {} as any), ]; const component = mountWithIntl( diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx index 80afc2894ea44..ae6a1b892bc99 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state.tsx @@ -10,12 +10,13 @@ import { EmptyStateError } from './empty_state_error'; import { EmptyStateLoading } from './empty_state_loading'; import { DataMissing } from './data_missing'; import { StatesIndexStatus } from '../../../../common/runtime_types'; +import { IHttpFetchError } from '../../../../../../../../target/types/core/public/http'; interface EmptyStateProps { children: JSX.Element[] | JSX.Element; statesIndexStatus: StatesIndexStatus | null; loading: boolean; - errors?: Error[]; + errors?: IHttpFetchError[]; } export const EmptyStateComponent = ({ diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx index c8e2bece1cb7f..1135b969018a1 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/empty_state/empty_state_error.tsx @@ -7,9 +7,10 @@ import { EuiEmptyPrompt, EuiPanel, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { Fragment } from 'react'; +import { IHttpFetchError } from '../../../../../../../../target/types/core/public/http'; interface EmptyStateErrorProps { - errors: Error[]; + errors: IHttpFetchError[]; } export const EmptyStateError = ({ errors }: EmptyStateErrorProps) => { diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts index 5252d90215e95..7b9b2d07f2a76 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/translations.ts @@ -64,3 +64,10 @@ export const UP = i18n.translate('xpack.uptime.monitorList.statusColumn.upLabel' export const DOWN = i18n.translate('xpack.uptime.monitorList.statusColumn.downLabel', { defaultMessage: 'Down', }); + +export const RESPONSE_ANOMALY_SCORE = i18n.translate( + 'xpack.uptime.monitorList.anomalyColumn.label', + { + defaultMessage: 'Response Anomaly Score', + } +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_name.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/location_name.tsx similarity index 100% rename from x-pack/legacy/plugins/uptime/public/components/functional/location_name.tsx rename to x-pack/legacy/plugins/uptime/public/components/functional/ping_list/location_name.tsx diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx index e8825dacc0078..d245bc1456e6a 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx @@ -29,7 +29,7 @@ import { Ping, PingResults } from '../../../../common/graphql/types'; import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order'; import { pingsQuery } from '../../../queries'; -import { LocationName } from './../location_name'; +import { LocationName } from './location_name'; import { Pagination } from './../monitor_list'; import { PingListExpandedRowComponent } from './expanded_row'; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap new file mode 100644 index 0000000000000..24ef7eda0d129 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/confirm_delete.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ML Confirm Job Delete shallow renders without errors 1`] = ` + + +

+ +

+

+ +

+
+
+`; + +exports[`ML Confirm Job Delete shallow renders without errors while loading 1`] = ` + + +

+ + ) +

+ +
+
+`; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap new file mode 100644 index 0000000000000..2457488c4facc --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/license_info.test.tsx.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShowLicenseInfo renders without errors 1`] = ` +Array [ +
+
+ +
+

+ In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license. +

+ + + + Start free 14-day trial + + + +
+
, +
, +] +`; + +exports[`ShowLicenseInfo shallow renders without errors 1`] = ` + + +

+ In order to access duration anomaly detection, you have to be subscribed to an Elastic Platinum license. +

+ + Start free 14-day trial + +
+ +
+`; diff --git a/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap new file mode 100644 index 0000000000000..354521e7c55b9 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/monitor_details/ml/__tests__/__snapshots__/ml_flyout.test.tsx.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ML Flyout component renders without errors 1`] = ` + + + +

+ Enable anomaly detection +

+
+ +
+ + + +

+ Here you can create a machine learning job to calculate anomaly scores on + response durations for Uptime Monitor. Once enabled, the monitor duration chart on the details page + will show the expected bounds and annotate the graph with anomalies. You can also potentially + identify periods of increased latency across geographical regions. +

+

+ + Machine Learning jobs management page + , + } + } + /> +

+

+ + Note: It might take a few minutes for the job to begin calculating results. + +

+
+ +
+ + + + + Create new job + + + + +
+`; + +exports[`ML Flyout component shows license info if no ml available 1`] = ` +
+
+
+
+