From 479223b0a1acf4926a18f61e2c62c458019f747d Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Mon, 3 Feb 2020 19:29:59 +0200 Subject: [PATCH 01/10] Update plugin generator to generate NP plugins (#55281) * Generate NP plugin * Added tsconfig * tsconfig * Adjust sao test * Add server side to plugin gen * Added navigation * add empty element * eslint * platform team CR * design CR improvements * text updates * temp disable plugin gen tests * eslint * Code review fixes * Add scss support - requires #53976 to be merged to work * CR fixes * comment fixes * Don't generate eslint for internal plugins by default * Update tests * reenable jest test for sao * Fix regex * review comments * code review Co-authored-by: Elastic Machine --- packages/kbn-plugin-generator/index.js | 16 ++- packages/kbn-plugin-generator/index.js.d.ts | 24 ++++ .../integration_tests/generate_plugin.test.js | 18 +-- .../kbn-plugin-generator/sao_template/sao.js | 69 +++++----- .../sao_template/sao.test.js | 127 ++++------------- .../sao_template/template/.i18nrc.json | 9 -- .../template/.kibana-plugin-helpers.json | 3 - .../sao_template/template/README.md | 31 +---- .../sao_template/template/common/index.ts | 2 + .../sao_template/template/eslintrc.js | 31 ++--- .../sao_template/template/gitignore | 6 - .../sao_template/template/index.js | 89 ------------ .../sao_template/template/kibana.json | 8 ++ .../template/package_template.json | 41 ------ .../template/public/__tests__/index.js | 7 - .../sao_template/template/public/app.js | 45 ------ .../template/public/application.tsx | 25 ++++ .../template/public/components/app.tsx | 129 ++++++++++++++++++ .../template/public/components/main/index.js | 1 - .../template/public/components/main/main.js | 97 ------------- .../sao_template/template/public/hack.js | 7 - .../template/public/{app.scss => index.scss} | 0 .../sao_template/template/public/index.ts | 16 +++ .../sao_template/template/public/plugin.ts | 42 ++++++ .../sao_template/template/public/types.ts | 11 ++ .../template/server/__tests__/index.js | 7 - .../sao_template/template/server/index.ts | 15 ++ .../sao_template/template/server/plugin.ts | 30 ++++ .../template/server/routes/example.js | 11 -- .../template/server/routes/index.ts | 17 +++ .../sao_template/template/server/types.ts | 4 + .../template/translations/zh-CN.json | 84 ------------ packages/kbn-plugin-generator/tsconfig.json | 5 + 33 files changed, 412 insertions(+), 615 deletions(-) create mode 100644 packages/kbn-plugin-generator/index.js.d.ts delete mode 100644 packages/kbn-plugin-generator/sao_template/template/.i18nrc.json delete mode 100644 packages/kbn-plugin-generator/sao_template/template/.kibana-plugin-helpers.json create mode 100644 packages/kbn-plugin-generator/sao_template/template/common/index.ts mode change 100755 => 100644 packages/kbn-plugin-generator/sao_template/template/eslintrc.js delete mode 100755 packages/kbn-plugin-generator/sao_template/template/gitignore delete mode 100755 packages/kbn-plugin-generator/sao_template/template/index.js create mode 100644 packages/kbn-plugin-generator/sao_template/template/kibana.json delete mode 100644 packages/kbn-plugin-generator/sao_template/template/package_template.json delete mode 100755 packages/kbn-plugin-generator/sao_template/template/public/__tests__/index.js delete mode 100755 packages/kbn-plugin-generator/sao_template/template/public/app.js create mode 100644 packages/kbn-plugin-generator/sao_template/template/public/application.tsx create mode 100644 packages/kbn-plugin-generator/sao_template/template/public/components/app.tsx delete mode 100644 packages/kbn-plugin-generator/sao_template/template/public/components/main/index.js delete mode 100644 packages/kbn-plugin-generator/sao_template/template/public/components/main/main.js delete mode 100755 packages/kbn-plugin-generator/sao_template/template/public/hack.js rename packages/kbn-plugin-generator/sao_template/template/public/{app.scss => index.scss} (100%) create mode 100644 packages/kbn-plugin-generator/sao_template/template/public/index.ts create mode 100644 packages/kbn-plugin-generator/sao_template/template/public/plugin.ts create mode 100644 packages/kbn-plugin-generator/sao_template/template/public/types.ts delete mode 100755 packages/kbn-plugin-generator/sao_template/template/server/__tests__/index.js create mode 100644 packages/kbn-plugin-generator/sao_template/template/server/index.ts create mode 100644 packages/kbn-plugin-generator/sao_template/template/server/plugin.ts delete mode 100755 packages/kbn-plugin-generator/sao_template/template/server/routes/example.js create mode 100644 packages/kbn-plugin-generator/sao_template/template/server/routes/index.ts create mode 100644 packages/kbn-plugin-generator/sao_template/template/server/types.ts delete mode 100644 packages/kbn-plugin-generator/sao_template/template/translations/zh-CN.json create mode 100644 packages/kbn-plugin-generator/tsconfig.json diff --git a/packages/kbn-plugin-generator/index.js b/packages/kbn-plugin-generator/index.js index 90274288357b8..15adce7f01c8e 100644 --- a/packages/kbn-plugin-generator/index.js +++ b/packages/kbn-plugin-generator/index.js @@ -29,6 +29,7 @@ exports.run = function run(argv) { const options = getopts(argv, { alias: { h: 'help', + i: 'internal', }, }); @@ -40,17 +41,22 @@ exports.run = function run(argv) { if (options.help) { console.log( dedent(chalk` - {dim usage:} node scripts/generate-plugin {bold [name]} - - generate a fresh Kibana plugin in the plugins/ directory + # {dim Usage:} + node scripts/generate-plugin {bold [name]} + Generate a fresh Kibana plugin in the plugins/ directory + + # {dim Core Kibana plugins:} + node scripts/generate-plugin {bold [name]} -i + To generate a core Kibana plugin inside the src/plugins/ directory, add the -i flag. `) + '\n' ); process.exit(1); } const name = options._[0]; + const isKibanaPlugin = options.internal; const template = resolve(__dirname, './sao_template'); - const kibanaPlugins = resolve(__dirname, '../../plugins'); + const kibanaPlugins = resolve(__dirname, isKibanaPlugin ? '../../src/plugins' : '../../plugins'); const targetPath = resolve(kibanaPlugins, snakeCase(name)); sao({ @@ -58,6 +64,8 @@ exports.run = function run(argv) { targetPath: targetPath, configOptions: { name, + isKibanaPlugin, + targetPath, }, }).catch(error => { console.error(chalk`{red fatal error}!`); diff --git a/packages/kbn-plugin-generator/index.js.d.ts b/packages/kbn-plugin-generator/index.js.d.ts new file mode 100644 index 0000000000000..46f7c43fd5790 --- /dev/null +++ b/packages/kbn-plugin-generator/index.js.d.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. + */ +interface PluginGenerator { + /** + * Run plugin generator. + */ + run: (...args: any[]) => any; +} diff --git a/packages/kbn-plugin-generator/integration_tests/generate_plugin.test.js b/packages/kbn-plugin-generator/integration_tests/generate_plugin.test.js index aa6611f3b6738..129125c4583d5 100644 --- a/packages/kbn-plugin-generator/integration_tests/generate_plugin.test.js +++ b/packages/kbn-plugin-generator/integration_tests/generate_plugin.test.js @@ -61,7 +61,8 @@ describe(`running the plugin-generator via 'node scripts/generate_plugin.js plug expect(stats.isDirectory()).toBe(true); }); - it(`should create an internationalization config file with a blank line appended to satisfy the parser`, async () => { + // skipped until internationalization is re-introduced + it.skip(`should create an internationalization config file with a blank line appended to satisfy the parser`, async () => { // Link to the error that happens when the blank line is not there: // https://github.com/elastic/kibana/pull/45044#issuecomment-530092627 const intlFile = `${generatedPath}/.i18nrc.json`; @@ -78,16 +79,7 @@ describe(`running the plugin-generator via 'node scripts/generate_plugin.js plug }); }); - it(`'yarn test:server' should exit 0`, async () => { - await execa('yarn', ['test:server'], { - cwd: generatedPath, - env: { - DISABLE_JUNIT_REPORTER: '1', - }, - }); - }); - - it(`'yarn build' should exit 0`, async () => { + it.skip(`'yarn build' should exit 0`, async () => { await execa('yarn', ['build'], { cwd: generatedPath }); }); @@ -109,7 +101,7 @@ describe(`running the plugin-generator via 'node scripts/generate_plugin.js plug '--migrations.skip=true', ], cwd: generatedPath, - wait: /ispec_plugin.+Status changed from uninitialized to green - Ready/, + wait: new RegExp('\\[ispecPlugin\\]\\[plugins\\] Setting up plugin'), }); await proc.stop('kibana'); }); @@ -120,7 +112,7 @@ describe(`running the plugin-generator via 'node scripts/generate_plugin.js plug await execa('yarn', ['preinstall'], { cwd: generatedPath }); }); - it(`'yarn lint' should exit 0`, async () => { + it.skip(`'yarn lint' should exit 0`, async () => { await execa('yarn', ['lint'], { cwd: generatedPath }); }); diff --git a/packages/kbn-plugin-generator/sao_template/sao.js b/packages/kbn-plugin-generator/sao_template/sao.js index f7401cba84358..aed4b9a02838f 100755 --- a/packages/kbn-plugin-generator/sao_template/sao.js +++ b/packages/kbn-plugin-generator/sao_template/sao.js @@ -17,21 +17,19 @@ * under the License. */ -const { resolve, relative, dirname } = require('path'); +const { relative } = require('path'); const startCase = require('lodash.startcase'); const camelCase = require('lodash.camelcase'); const snakeCase = require('lodash.snakecase'); -const execa = require('execa'); const chalk = require('chalk'); +const execa = require('execa'); const pkg = require('../package.json'); const kibanaPkgPath = require.resolve('../../../package.json'); const kibanaPkg = require(kibanaPkgPath); // eslint-disable-line import/no-dynamic-require -const KBN_DIR = dirname(kibanaPkgPath); - -module.exports = function({ name }) { +module.exports = function({ name, targetPath, isKibanaPlugin }) { return { prompts: { description: { @@ -47,41 +45,38 @@ module.exports = function({ name }) { message: 'Should an app component be generated?', default: true, }, - generateTranslations: { - type: 'confirm', - message: 'Should translation files be generated?', - default: true, - }, - generateHack: { - type: 'confirm', - message: 'Should a hack component be generated?', - default: true, - }, generateApi: { type: 'confirm', message: 'Should a server API be generated?', default: true, }, + // generateTranslations: { + // type: 'confirm', + // message: 'Should translation files be generated?', + // default: true, + // }, generateScss: { type: 'confirm', message: 'Should SCSS be used?', when: answers => answers.generateApp, default: true, }, + generateEslint: { + type: 'confirm', + message: 'Would you like to use a custom eslint file?', + default: !isKibanaPlugin, + }, }, filters: { + 'public/**/index.scss': 'generateScss', 'public/**/*': 'generateApp', - 'translations/**/*': 'generateTranslations', - '.i18nrc.json': 'generateTranslations', - 'public/hack.js': 'generateHack', 'server/**/*': 'generateApi', - 'public/app.scss': 'generateScss', - '.kibana-plugin-helpers.json': 'generateScss', + // 'translations/**/*': 'generateTranslations', + // '.i18nrc.json': 'generateTranslations', + 'eslintrc.js': 'generateEslint', }, move: { - gitignore: '.gitignore', 'eslintrc.js': '.eslintrc.js', - 'package_template.json': 'package.json', }, data: answers => Object.assign( @@ -91,34 +86,36 @@ module.exports = function({ name }) { camelCase, snakeCase, name, + isKibanaPlugin, + kbnVersion: answers.kbnVersion, + upperCamelCaseName: name.charAt(0).toUpperCase() + camelCase(name).slice(1), + hasUi: !!answers.generateApp, + hasServer: !!answers.generateApi, + hasScss: !!answers.generateScss, + relRoot: isKibanaPlugin ? '../../../..' : '../../..', }, answers ), enforceNewFolder: true, installDependencies: false, - gitInit: true, + gitInit: !isKibanaPlugin, async post({ log }) { - await execa('yarn', ['kbn', 'bootstrap'], { - cwd: KBN_DIR, - stdio: 'inherit', - }); - - const dir = relative(process.cwd(), resolve(KBN_DIR, 'plugins', snakeCase(name))); + const dir = relative(process.cwd(), targetPath); + // Apply eslint to the generated plugin try { - await execa('yarn', ['lint', '--fix'], { - cwd: dir, - all: true, - }); + await execa('yarn', ['lint:es', `./${dir}/**/*.ts*`, '--no-ignore', '--fix']); } catch (error) { - throw new Error(`Failure when running prettier on the generated output: ${error.all}`); + console.error(error); + throw new Error( + `Failure when running prettier on the generated output: ${error.all || error}` + ); } log.success(chalk`🎉 -Your plugin has been created in {bold ${dir}}. Move into that directory to run it: +Your plugin has been created in {bold ${dir}}. - {bold cd "${dir}"} {bold yarn start} `); }, diff --git a/packages/kbn-plugin-generator/sao_template/sao.test.js b/packages/kbn-plugin-generator/sao_template/sao.test.js index 80149c008dad8..0dbdb7d3c097b 100755 --- a/packages/kbn-plugin-generator/sao_template/sao.test.js +++ b/packages/kbn-plugin-generator/sao_template/sao.test.js @@ -19,8 +19,6 @@ const sao = require('sao'); -const templatePkg = require('../package.json'); - const template = { fromPath: __dirname, configOptions: { @@ -32,121 +30,57 @@ function getFileContents(file) { return file.contents.toString(); } -function getConfig(file) { - const contents = getFileContents(file).replace(/\r?\n/gm, ''); - return contents.split('kibana.Plugin(')[1]; -} - describe('plugin generator sao integration', () => { test('skips files when answering no', async () => { const res = await sao.mockPrompt(template, { generateApp: false, - generateHack: false, generateApi: false, }); - expect(res.fileList).not.toContain('public/app.js'); - expect(res.fileList).not.toContain('public/__tests__/index.js'); - expect(res.fileList).not.toContain('public/hack.js'); - expect(res.fileList).not.toContain('server/routes/example.js'); - expect(res.fileList).not.toContain('server/__tests__/index.js'); - - const uiExports = getConfig(res.files['index.js']); - expect(uiExports).not.toContain('app:'); - expect(uiExports).not.toContain('hacks:'); - expect(uiExports).not.toContain('init(server, options)'); - expect(uiExports).not.toContain('registerFeature('); + expect(res.fileList).toContain('common/index.ts'); + expect(res.fileList).not.toContain('public/index.ts'); + expect(res.fileList).not.toContain('server/index.ts'); }); it('includes app when answering yes', async () => { const res = await sao.mockPrompt(template, { generateApp: true, - generateHack: false, - generateApi: false, - }); - - // check output files - expect(res.fileList).toContain('public/app.js'); - expect(res.fileList).toContain('public/__tests__/index.js'); - expect(res.fileList).not.toContain('public/hack.js'); - expect(res.fileList).not.toContain('server/routes/example.js'); - expect(res.fileList).not.toContain('server/__tests__/index.js'); - - const uiExports = getConfig(res.files['index.js']); - expect(uiExports).toContain('app:'); - expect(uiExports).toContain('init(server, options)'); - expect(uiExports).toContain('registerFeature('); - expect(uiExports).not.toContain('hacks:'); - }); - - it('includes hack when answering yes', async () => { - const res = await sao.mockPrompt(template, { - generateApp: true, - generateHack: true, generateApi: false, }); // check output files - expect(res.fileList).toContain('public/app.js'); - expect(res.fileList).toContain('public/__tests__/index.js'); - expect(res.fileList).toContain('public/hack.js'); - expect(res.fileList).not.toContain('server/routes/example.js'); - expect(res.fileList).not.toContain('server/__tests__/index.js'); - - const uiExports = getConfig(res.files['index.js']); - expect(uiExports).toContain('app:'); - expect(uiExports).toContain('hacks:'); - expect(uiExports).toContain('init(server, options)'); - expect(uiExports).toContain('registerFeature('); + expect(res.fileList).toContain('common/index.ts'); + expect(res.fileList).toContain('public/index.ts'); + expect(res.fileList).toContain('public/plugin.ts'); + expect(res.fileList).toContain('public/types.ts'); + expect(res.fileList).toContain('public/components/app.tsx'); + expect(res.fileList).not.toContain('server/index.ts'); }); it('includes server api when answering yes', async () => { const res = await sao.mockPrompt(template, { generateApp: true, - generateHack: true, generateApi: true, }); // check output files - expect(res.fileList).toContain('public/app.js'); - expect(res.fileList).toContain('public/__tests__/index.js'); - expect(res.fileList).toContain('public/hack.js'); - expect(res.fileList).toContain('server/routes/example.js'); - expect(res.fileList).toContain('server/__tests__/index.js'); - - const uiExports = getConfig(res.files['index.js']); - expect(uiExports).toContain('app:'); - expect(uiExports).toContain('hacks:'); - expect(uiExports).toContain('init(server, options)'); - expect(uiExports).toContain('registerFeature('); - }); - - it('plugin config has correct name and main path', async () => { - const res = await sao.mockPrompt(template, { - generateApp: true, - generateHack: true, - generateApi: true, - }); - - const indexContents = getFileContents(res.files['index.js']); - const nameLine = indexContents.match('name: (.*)')[1]; - const mainLine = indexContents.match('main: (.*)')[1]; - - expect(nameLine).toContain('some_fancy_plugin'); - expect(mainLine).toContain('plugins/some_fancy_plugin/app'); + expect(res.fileList).toContain('public/plugin.ts'); + expect(res.fileList).toContain('server/plugin.ts'); + expect(res.fileList).toContain('server/index.ts'); + expect(res.fileList).toContain('server/types.ts'); + expect(res.fileList).toContain('server/routes/index.ts'); }); - it('plugin package has correct name', async () => { + it('plugin package has correct title', async () => { const res = await sao.mockPrompt(template, { generateApp: true, - generateHack: true, generateApi: true, }); - const packageContents = getFileContents(res.files['package.json']); - const pkg = JSON.parse(packageContents); + const contents = getFileContents(res.files['common/index.ts']); + const controllerLine = contents.match("PLUGIN_NAME = '(.*)'")[1]; - expect(pkg.name).toBe('some_fancy_plugin'); + expect(controllerLine).toContain('Some fancy plugin'); }); it('package has version "kibana" with master', async () => { @@ -154,10 +88,10 @@ describe('plugin generator sao integration', () => { kbnVersion: 'master', }); - const packageContents = getFileContents(res.files['package.json']); + const packageContents = getFileContents(res.files['kibana.json']); const pkg = JSON.parse(packageContents); - expect(pkg.kibana.version).toBe('kibana'); + expect(pkg.version).toBe('master'); }); it('package has correct version', async () => { @@ -165,39 +99,26 @@ describe('plugin generator sao integration', () => { kbnVersion: 'v6.0.0', }); - const packageContents = getFileContents(res.files['package.json']); - const pkg = JSON.parse(packageContents); - - expect(pkg.kibana.version).toBe('v6.0.0'); - }); - - it('package has correct templateVersion', async () => { - const res = await sao.mockPrompt(template, { - kbnVersion: 'master', - }); - - const packageContents = getFileContents(res.files['package.json']); + const packageContents = getFileContents(res.files['kibana.json']); const pkg = JSON.parse(packageContents); - expect(pkg.kibana.templateVersion).toBe(templatePkg.version); + expect(pkg.version).toBe('v6.0.0'); }); it('sample app has correct values', async () => { const res = await sao.mockPrompt(template, { generateApp: true, - generateHack: true, generateApi: true, }); - const contents = getFileContents(res.files['public/app.js']); - const controllerLine = contents.match('setRootController(.*)')[1]; + const contents = getFileContents(res.files['common/index.ts']); + const controllerLine = contents.match("PLUGIN_ID = '(.*)'")[1]; expect(controllerLine).toContain('someFancyPlugin'); }); it('includes dotfiles', async () => { const res = await sao.mockPrompt(template); - expect(res.files['.gitignore']).toBeTruthy(); expect(res.files['.eslintrc.js']).toBeTruthy(); }); }); diff --git a/packages/kbn-plugin-generator/sao_template/template/.i18nrc.json b/packages/kbn-plugin-generator/sao_template/template/.i18nrc.json deleted file mode 100644 index 1a8aea8853876..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/.i18nrc.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "paths": { - "<%= camelCase(name) %>": "./" - }, - "translations": [ - "translations/zh-CN.json" - ] -} - diff --git a/packages/kbn-plugin-generator/sao_template/template/.kibana-plugin-helpers.json b/packages/kbn-plugin-generator/sao_template/template/.kibana-plugin-helpers.json deleted file mode 100644 index 383368c7f8ce1..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/.kibana-plugin-helpers.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "styleSheetToCompile": "public/app.scss" -} diff --git a/packages/kbn-plugin-generator/sao_template/template/README.md b/packages/kbn-plugin-generator/sao_template/template/README.md index 59c3adf2713c8..1e0139428fcbc 100755 --- a/packages/kbn-plugin-generator/sao_template/template/README.md +++ b/packages/kbn-plugin-generator/sao_template/template/README.md @@ -6,34 +6,7 @@ --- -## development +## Development -See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment. Once you have completed that, use the following yarn scripts. +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment. - - `yarn kbn bootstrap` - - Install dependencies and crosslink Kibana and all projects/plugins. - - > ***IMPORTANT:*** Use this script instead of `yarn` to install dependencies when switching branches, and re-run it whenever your dependencies change. - - - `yarn start` - - Start kibana and have it include this plugin. You can pass any arguments that you would normally send to `bin/kibana` - - ``` - yarn start --elasticsearch.hosts http://localhost:9220 - ``` - - - `yarn build` - - Build a distributable archive of your plugin. - - - `yarn test:browser` - - Run the browser tests in a real web browser. - - - `yarn test:mocha` - - Run the server tests using mocha. - -For more information about any of these commands run `yarn ${task} --help`. For a full list of tasks checkout the `package.json` file, or run `yarn run`. diff --git a/packages/kbn-plugin-generator/sao_template/template/common/index.ts b/packages/kbn-plugin-generator/sao_template/template/common/index.ts new file mode 100644 index 0000000000000..90ffcb70045aa --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/common/index.ts @@ -0,0 +1,2 @@ +export const PLUGIN_ID = '<%= camelCase(name) %>'; +export const PLUGIN_NAME = '<%= name %>'; diff --git a/packages/kbn-plugin-generator/sao_template/template/eslintrc.js b/packages/kbn-plugin-generator/sao_template/template/eslintrc.js old mode 100755 new mode 100644 index e1dfadc212b7e..b68d42e32e047 --- a/packages/kbn-plugin-generator/sao_template/template/eslintrc.js +++ b/packages/kbn-plugin-generator/sao_template/template/eslintrc.js @@ -1,24 +1,9 @@ -module.exports = { - root: true, +module.exports = { + root: true, extends: ['@elastic/eslint-config-kibana', 'plugin:@elastic/eui/recommended'], - settings: { - 'import/resolver': { - '@kbn/eslint-import-resolver-kibana': { - rootPackageName: '<%= snakeCase(name) %>', - }, - }, - }, - overrides: [ - { - files: ['**/public/**/*'], - settings: { - 'import/resolver': { - '@kbn/eslint-import-resolver-kibana': { - forceNode: false, - rootPackageName: '<%= snakeCase(name) %>', - }, - }, - }, - }, - ] -}; + <%_ if (!isKibanaPlugin) { -%> + rules: { + "@kbn/eslint/require-license-header": "off" + } + <%_ } -%> +}; \ No newline at end of file diff --git a/packages/kbn-plugin-generator/sao_template/template/gitignore b/packages/kbn-plugin-generator/sao_template/template/gitignore deleted file mode 100755 index db28fed19376d..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/gitignore +++ /dev/null @@ -1,6 +0,0 @@ -npm-debug.log* -node_modules -/build/ -<%_ if (generateScss) { -%> -/public/app.css -<%_ } -%> diff --git a/packages/kbn-plugin-generator/sao_template/template/index.js b/packages/kbn-plugin-generator/sao_template/template/index.js deleted file mode 100755 index 4bc3347ae6019..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/index.js +++ /dev/null @@ -1,89 +0,0 @@ -<% if (generateScss) { -%> -import { resolve } from 'path'; -import { existsSync } from 'fs'; - -<% } -%> - -<% if (generateApp) { -%> -import { i18n } from '@kbn/i18n'; -<% } -%> - -<% if (generateApi) { -%> -import exampleRoute from './server/routes/example'; - -<% } -%> -export default function (kibana) { - return new kibana.Plugin({ - require: ['elasticsearch'], - name: '<%= snakeCase(name) %>', - uiExports: { - <%_ if (generateApp) { -%> - app: { - title: '<%= startCase(name) %>', - description: '<%= description %>', - main: 'plugins/<%= snakeCase(name) %>/app', - }, - <%_ } -%> - <%_ if (generateHack) { -%> - hacks: [ - 'plugins/<%= snakeCase(name) %>/hack' - ], - <%_ } -%> - <%_ if (generateScss) { -%> - styleSheetPaths: [resolve(__dirname, 'public/app.scss'), resolve(__dirname, 'public/app.css')].find(p => existsSync(p)), - <%_ } -%> - }, - - config(Joi) { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - <%_ if (generateApi || generateApp) { -%> - - // eslint-disable-next-line no-unused-vars - init(server, options) { - <%_ if (generateApp) { -%> - const xpackMainPlugin = server.plugins.xpack_main; - if (xpackMainPlugin) { - const featureId = '<%= snakeCase(name) %>'; - - xpackMainPlugin.registerFeature({ - id: featureId, - name: i18n.translate('<%= camelCase(name) %>.featureRegistry.featureName', { - defaultMessage: '<%= name %>', - }), - navLinkId: featureId, - icon: 'questionInCircle', - app: [featureId, 'kibana'], - catalogue: [], - privileges: { - all: { - api: [], - savedObject: { - all: [], - read: [], - }, - ui: ['show'], - }, - read: { - api: [], - savedObject: { - all: [], - read: [], - }, - ui: ['show'], - }, - }, - }); - } - <%_ } -%> - - <%_ if (generateApi) { -%> - // Add server routes and initialize the plugin here - exampleRoute(server); - <%_ } -%> - } - <%_ } -%> - }); -} diff --git a/packages/kbn-plugin-generator/sao_template/template/kibana.json b/packages/kbn-plugin-generator/sao_template/template/kibana.json new file mode 100644 index 0000000000000..f8bb07040abeb --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "<%= camelCase(name) %>", + "version": "<%= kbnVersion %>", + "server": <%= hasServer %>, + "ui": <%= hasUi %>, + "requiredPlugins": ["navigation"], + "optionalPlugins": [] +} diff --git a/packages/kbn-plugin-generator/sao_template/template/package_template.json b/packages/kbn-plugin-generator/sao_template/template/package_template.json deleted file mode 100644 index 4b6629fa90268..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/package_template.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "<%= snakeCase(name) %>", - "version": "0.0.0", - "description": "<%= description %>", - "main": "index.js", - "kibana": { - "version": "<%= (kbnVersion === 'master') ? 'kibana' : kbnVersion %>", - "templateVersion": "<%= templateVersion %>" - }, - "scripts": { - "preinstall": "node ../../preinstall_check", - "kbn": "node ../../scripts/kbn", - "es": "node ../../scripts/es", - "lint": "eslint .", - "start": "plugin-helpers start", - "test:server": "plugin-helpers test:server", - "test:browser": "plugin-helpers test:browser", - "build": "plugin-helpers build" - }, - <%_ if (generateTranslations) { _%> - "dependencies": { - "@kbn/i18n": "link:../../packages/kbn-i18n" - }, - <%_ } _%> - "devDependencies": { - "@elastic/eslint-config-kibana": "link:../../packages/eslint-config-kibana", - "@elastic/eslint-import-resolver-kibana": "link:../../packages/kbn-eslint-import-resolver-kibana", - "@kbn/expect": "link:../../packages/kbn-expect", - "@kbn/plugin-helpers": "link:../../packages/kbn-plugin-helpers", - "babel-eslint": "^10.0.1", - "eslint": "^5.16.0", - "eslint-plugin-babel": "^5.3.0", - "eslint-plugin-import": "^2.16.0", - "eslint-plugin-jest": "^22.4.1", - "eslint-plugin-jsx-a11y": "^6.2.1", - "eslint-plugin-mocha": "^5.3.0", - "eslint-plugin-no-unsanitized": "^3.0.2", - "eslint-plugin-prefer-object-spread": "^1.2.1", - "eslint-plugin-react": "^7.12.4" - } -} diff --git a/packages/kbn-plugin-generator/sao_template/template/public/__tests__/index.js b/packages/kbn-plugin-generator/sao_template/template/public/__tests__/index.js deleted file mode 100755 index 9320bd7b028a8..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/public/__tests__/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import expect from '@kbn/expect'; - -describe('suite', () => { - it('is a test', () => { - expect(true).to.equal(true); - }); -}); diff --git a/packages/kbn-plugin-generator/sao_template/template/public/app.js b/packages/kbn-plugin-generator/sao_template/template/public/app.js deleted file mode 100755 index 37a7c37e916a0..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/public/app.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { uiModules } from 'ui/modules'; -import chrome from 'ui/chrome'; -import { render, unmountComponentAtNode } from 'react-dom'; -<%_ if (generateTranslations) { _%> -import { I18nProvider } from '@kbn/i18n/react'; -<%_ } _%> - -import { Main } from './components/main'; - -const app = uiModules.get('apps/<%= camelCase(name) %>'); - -app.config($locationProvider => { - $locationProvider.html5Mode({ - enabled: false, - requireBase: false, - rewriteLinks: false, - }); -}); -app.config(stateManagementConfigProvider => - stateManagementConfigProvider.disable() -); - -function RootController($scope, $element, $http) { - const domNode = $element[0]; - - // render react to DOM - <%_ if (generateTranslations) { _%> - render( - -
- , - domNode - ); - <%_ } else { _%> - render(
, domNode); - <%_ } _%> - - // unmount react on controller destroy - $scope.$on('$destroy', () => { - unmountComponentAtNode(domNode); - }); -} - -chrome.setRootController('<%= camelCase(name) %>', RootController); diff --git a/packages/kbn-plugin-generator/sao_template/template/public/application.tsx b/packages/kbn-plugin-generator/sao_template/template/public/application.tsx new file mode 100644 index 0000000000000..8106a18a784e7 --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/public/application.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, CoreStart } from '<%= relRoot %>/src/core/public'; +import { AppPluginStartDependencies } from './types'; +import { <%= upperCamelCaseName %>App } from './components/app'; + + +export const renderApp = ( + { notifications, http }: CoreStart, + { navigation }: AppPluginStartDependencies, + { appBasePath, element }: AppMountParameters + ) => { + ReactDOM.render( + <<%= upperCamelCaseName %>App + basename={appBasePath} + notifications={notifications} + http={http} + navigation={navigation} + />, + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); + }; + \ No newline at end of file diff --git a/packages/kbn-plugin-generator/sao_template/template/public/components/app.tsx b/packages/kbn-plugin-generator/sao_template/template/public/components/app.tsx new file mode 100644 index 0000000000000..7b259a9c5b99d --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/public/components/app.tsx @@ -0,0 +1,129 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { BrowserRouter as Router } from 'react-router-dom'; + +import { + EuiButton, + EuiHorizontalRule, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageHeader, + EuiTitle, + EuiText, +} from '@elastic/eui'; + +import { CoreStart } from '<%= relRoot %>/../src/core/public'; +import { NavigationPublicPluginStart } from '<%= relRoot %>/../src/plugins/navigation/public'; + +import { PLUGIN_ID, PLUGIN_NAME } from '../../common'; + +interface <%= upperCamelCaseName %>AppDeps { + basename: string; + notifications: CoreStart['notifications']; + http: CoreStart['http']; + navigation: NavigationPublicPluginStart; +} + +export const <%= upperCamelCaseName %>App = ({ basename, notifications, http, navigation }: <%= upperCamelCaseName %>AppDeps) => { + // Use React hooks to manage state. + const [timestamp, setTimestamp] = useState(); + + const onClickHandler = () => { +<%_ if (generateApi) { -%> + // Use the core http service to make a response to the server API. + http.get('/api/<%= snakeCase(name) %>/example').then(res => { + setTimestamp(res.time); + // Use the core notifications service to display a success message. + notifications.toasts.addSuccess(i18n.translate('<%= camelCase(name) %>.dataUpdated', { + defaultMessage: 'Data updated', + })); + }); +<%_ } else { -%> + setTimestamp(new Date().toISOString()); + notifications.toasts.addSuccess(PLUGIN_NAME); +<%_ } -%> + }; + + // Render the application DOM. + // Note that `navigation.ui.TopNavMenu` is a stateful component exported on the `navigation` plugin's start contract. + return ( + + + <> + + + + + +

+ +

+
+
+ + + +

+ +

+
+
+ + +

+ +

+ +

+ +

+ + + +
+
+
+
+
+ +
+
+ ); +}; diff --git a/packages/kbn-plugin-generator/sao_template/template/public/components/main/index.js b/packages/kbn-plugin-generator/sao_template/template/public/components/main/index.js deleted file mode 100644 index 68710baa1bee8..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/public/components/main/index.js +++ /dev/null @@ -1 +0,0 @@ -export { Main } from './main'; diff --git a/packages/kbn-plugin-generator/sao_template/template/public/components/main/main.js b/packages/kbn-plugin-generator/sao_template/template/public/components/main/main.js deleted file mode 100644 index 59fd667c709aa..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/public/components/main/main.js +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import { - EuiPage, - EuiPageHeader, - EuiTitle, - EuiPageBody, - EuiPageContent, - EuiPageContentHeader, - EuiPageContentBody, - EuiText -} from '@elastic/eui'; -<%_ if (generateTranslations) { _%> -import { FormattedMessage } from '@kbn/i18n/react'; -<%_ } _%> - -export class Main extends React.Component { - constructor(props) { - super(props); - this.state = {}; - } - - componentDidMount() { - /* - FOR EXAMPLE PURPOSES ONLY. There are much better ways to - manage state and update your UI than this. - */ - const { httpClient } = this.props; - httpClient.get('../api/<%= name %>/example').then((resp) => { - this.setState({ time: resp.data.time }); - }); - } - render() { - const { title } = this.props; - return ( - - - - -

- <%_ if (generateTranslations) { _%> - - <%_ } else { _%> - {title} Hello World! - <%_ } _%> -

-
-
- - - -

- <%_ if (generateTranslations) { _%> - - <%_ } else { _%> - Congratulations - <%_ } _%> -

-
-
- - -

- <%_ if (generateTranslations) { _%> - - <%_ } else { _%> - You have successfully created your first Kibana Plugin! - <%_ } _%> -

-

- <%_ if (generateTranslations) { _%> - - <%_ } else { _%> - The server time (via API call) is {this.state.time || 'NO API CALL YET'} - <%_ } _%> -

-
-
-
-
-
- ); - } -} diff --git a/packages/kbn-plugin-generator/sao_template/template/public/hack.js b/packages/kbn-plugin-generator/sao_template/template/public/hack.js deleted file mode 100755 index 775526c8e44a3..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/public/hack.js +++ /dev/null @@ -1,7 +0,0 @@ -import $ from 'jquery'; - -$(document.body).on('keypress', function (event) { - if (event.which === 58) { - alert('boo!'); - } -}); diff --git a/packages/kbn-plugin-generator/sao_template/template/public/app.scss b/packages/kbn-plugin-generator/sao_template/template/public/index.scss similarity index 100% rename from packages/kbn-plugin-generator/sao_template/template/public/app.scss rename to packages/kbn-plugin-generator/sao_template/template/public/index.scss diff --git a/packages/kbn-plugin-generator/sao_template/template/public/index.ts b/packages/kbn-plugin-generator/sao_template/template/public/index.ts new file mode 100644 index 0000000000000..2999dc7264ddb --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/public/index.ts @@ -0,0 +1,16 @@ +<%_ if (hasScss) { -%> +import './index.scss'; +<%_ } -%> + +import { <%= upperCamelCaseName %>Plugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export function plugin() { + return new <%= upperCamelCaseName %>Plugin(); +} +export { + <%= upperCamelCaseName %>PluginSetup, + <%= upperCamelCaseName %>PluginStart, +} from './types'; + diff --git a/packages/kbn-plugin-generator/sao_template/template/public/plugin.ts b/packages/kbn-plugin-generator/sao_template/template/public/plugin.ts new file mode 100644 index 0000000000000..76f7f1a6f9908 --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/public/plugin.ts @@ -0,0 +1,42 @@ +import { i18n } from '@kbn/i18n'; +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '<%= relRoot %>/src/core/public'; +import { <%= upperCamelCaseName %>PluginSetup, <%= upperCamelCaseName %>PluginStart, AppPluginStartDependencies } from './types'; +import { PLUGIN_NAME } from '../common'; + +export class <%= upperCamelCaseName %>Plugin + implements Plugin<<%= upperCamelCaseName %>PluginSetup, <%= upperCamelCaseName %>PluginStart> { + + public setup(core: CoreSetup): <%= upperCamelCaseName %>PluginSetup { + // Register an application into the side navigation menu + core.application.register({ + id: '<%= camelCase(name) %>', + title: PLUGIN_NAME, + async mount(params: AppMountParameters) { + // Load application bundle + const { renderApp } = await import('./application'); + // Get start services as specified in kibana.json + const [coreStart, depsStart] = await core.getStartServices(); + // Render the application + return renderApp(coreStart, depsStart as AppPluginStartDependencies, params); + }, + }); + + // Return methods that should be available to other plugins + return { + getGreeting() { + return i18n.translate('<%= camelCase(name) %>.greetingText', { + defaultMessage: 'Hello from {name}!', + values: { + name: PLUGIN_NAME, + }, + }); + }, + }; + } + + public start(core: CoreStart): <%= upperCamelCaseName %>PluginStart { + return {}; + } + + public stop() {} +} diff --git a/packages/kbn-plugin-generator/sao_template/template/public/types.ts b/packages/kbn-plugin-generator/sao_template/template/public/types.ts new file mode 100644 index 0000000000000..2ebb0c0d1257f --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/public/types.ts @@ -0,0 +1,11 @@ +import { NavigationPublicPluginStart } from '<%= relRoot %>/src/plugins/navigation/public'; + +export interface <%= upperCamelCaseName %>PluginSetup { + getGreeting: () => string; +} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface <%= upperCamelCaseName %>PluginStart {} + +export interface AppPluginStartDependencies { + navigation: NavigationPublicPluginStart +}; diff --git a/packages/kbn-plugin-generator/sao_template/template/server/__tests__/index.js b/packages/kbn-plugin-generator/sao_template/template/server/__tests__/index.js deleted file mode 100755 index 9320bd7b028a8..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/server/__tests__/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import expect from '@kbn/expect'; - -describe('suite', () => { - it('is a test', () => { - expect(true).to.equal(true); - }); -}); diff --git a/packages/kbn-plugin-generator/sao_template/template/server/index.ts b/packages/kbn-plugin-generator/sao_template/template/server/index.ts new file mode 100644 index 0000000000000..816b8faec2a45 --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/server/index.ts @@ -0,0 +1,15 @@ +import { PluginInitializerContext } from '<%= relRoot %>/src/core/server'; +import { <%= upperCamelCaseName %>Plugin } from './plugin'; + + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. + + export function plugin(initializerContext: PluginInitializerContext) { + return new <%= upperCamelCaseName %>Plugin(initializerContext); +} + +export { + <%= upperCamelCaseName %>PluginSetup, + <%= upperCamelCaseName %>PluginStart, +} from './types'; diff --git a/packages/kbn-plugin-generator/sao_template/template/server/plugin.ts b/packages/kbn-plugin-generator/sao_template/template/server/plugin.ts new file mode 100644 index 0000000000000..d6a343209e39e --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/server/plugin.ts @@ -0,0 +1,30 @@ +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '<%= relRoot %>/src/core/server'; + +import { <%= upperCamelCaseName %>PluginSetup, <%= upperCamelCaseName %>PluginStart } from './types'; +import { defineRoutes } from './routes'; + +export class <%= upperCamelCaseName %>Plugin + implements Plugin<<%= upperCamelCaseName %>PluginSetup, <%= upperCamelCaseName %>PluginStart> { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + this.logger.debug('<%= name %>: Setup'); + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('<%= name %>: Started'); + return {}; + } + + public stop() {} +} diff --git a/packages/kbn-plugin-generator/sao_template/template/server/routes/example.js b/packages/kbn-plugin-generator/sao_template/template/server/routes/example.js deleted file mode 100755 index 5a612645f48fc..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/server/routes/example.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function (server) { - - server.route({ - path: '/api/<%= name %>/example', - method: 'GET', - handler() { - return { time: (new Date()).toISOString() }; - } - }); - -} diff --git a/packages/kbn-plugin-generator/sao_template/template/server/routes/index.ts b/packages/kbn-plugin-generator/sao_template/template/server/routes/index.ts new file mode 100644 index 0000000000000..d8bb00f0dea6c --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/server/routes/index.ts @@ -0,0 +1,17 @@ +import { IRouter } from '<%= relRoot %>/../src/core/server'; + +export function defineRoutes(router: IRouter) { + router.get( + { + path: '/api/<%= snakeCase(name) %>/example', + validate: false, + }, + async (context, request, response) => { + return response.ok({ + body: { + time: new Date().toISOString(), + }, + }); + } + ); +} diff --git a/packages/kbn-plugin-generator/sao_template/template/server/types.ts b/packages/kbn-plugin-generator/sao_template/template/server/types.ts new file mode 100644 index 0000000000000..adbc5e93f03c5 --- /dev/null +++ b/packages/kbn-plugin-generator/sao_template/template/server/types.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface <%= upperCamelCaseName %>PluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface <%= upperCamelCaseName %>PluginStart {} diff --git a/packages/kbn-plugin-generator/sao_template/template/translations/zh-CN.json b/packages/kbn-plugin-generator/sao_template/template/translations/zh-CN.json deleted file mode 100644 index 3447511c6739a..0000000000000 --- a/packages/kbn-plugin-generator/sao_template/template/translations/zh-CN.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "formats": { - "number": { - "currency": { - "style": "currency" - }, - "percent": { - "style": "percent" - } - }, - "date": { - "short": { - "month": "numeric", - "day": "numeric", - "year": "2-digit" - }, - "medium": { - "month": "short", - "day": "numeric", - "year": "numeric" - }, - "long": { - "month": "long", - "day": "numeric", - "year": "numeric" - }, - "full": { - "weekday": "long", - "month": "long", - "day": "numeric", - "year": "numeric" - } - }, - "time": { - "short": { - "hour": "numeric", - "minute": "numeric" - }, - "medium": { - "hour": "numeric", - "minute": "numeric", - "second": "numeric" - }, - "long": { - "hour": "numeric", - "minute": "numeric", - "second": "numeric", - "timeZoneName": "short" - }, - "full": { - "hour": "numeric", - "minute": "numeric", - "second": "numeric", - "timeZoneName": "short" - } - }, - "relative": { - "years": { - "units": "year" - }, - "months": { - "units": "month" - }, - "days": { - "units": "day" - }, - "hours": { - "units": "hour" - }, - "minutes": { - "units": "minute" - }, - "seconds": { - "units": "second" - } - } - }, - "messages": { - "<%= camelCase(name) %>.congratulationsText": "您已经成功创建第一个 Kibana 插件。", - "<%= camelCase(name) %>.congratulationsTitle": "恭喜!", - "<%= camelCase(name) %>.helloWorldText": "{title} 您好,世界!", - "<%= camelCase(name) %>.serverTimeText": "服务器时间(通过 API 调用)为 {time}" - } -} diff --git a/packages/kbn-plugin-generator/tsconfig.json b/packages/kbn-plugin-generator/tsconfig.json new file mode 100644 index 0000000000000..fe0f7112f1fa9 --- /dev/null +++ b/packages/kbn-plugin-generator/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["**/*", "index.js.d.ts"], + "exclude": ["sao_template/template/*"] +} From 4aa727560a72b3f284910dd34e78d5b9dbc81ebc Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 3 Feb 2020 19:00:40 +0100 Subject: [PATCH 02/10] fix timespan referencing to same values (#56601) (#56612) --- .../server/lib/adapters/monitor_states/search/query_context.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts index 961cc94dcea19..a51931ba11630 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitor_states/search/query_context.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; import { APICaller } from 'kibana/server'; import { CursorPagination } from '../adapter_types'; import { INDEX_NAMES } from '../../../../../common/constants'; @@ -97,7 +98,7 @@ export class QueryContext { // behavior. const tsEnd = parseRelativeDate(this.dateRangeEnd, { roundUp: true })!; - const tsStart = tsEnd.subtract(5, 'minutes'); + const tsStart = moment(tsEnd).subtract(5, 'minutes'); return { range: { From e28e149b46d27df6f4477a4e83600742431873b6 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Mon, 3 Feb 2020 11:20:41 -0800 Subject: [PATCH 03/10] Fix incorrect app name in ILM license checker. (#56355) --- .../server/lib/check_license/check_license.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/check_license.js b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/check_license.js index e71820a346f91..7534f3cd0934e 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/check_license.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/server/lib/check_license/check_license.js @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; export function checkLicense(xpackLicenseInfo) { - const pluginName = 'Index Management'; + const pluginName = 'Index Lifecycle Policies'; // If, for some reason, we cannot get the license information // from Elasticsearch, assume worst case and disable From 6398a9911df0d45ba72dafbb2d9c8398916ddba6 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Mon, 3 Feb 2020 15:55:50 -0500 Subject: [PATCH 04/10] [Monitoring] Migrate license expiration alert to Kibana alerting (#54306) * License expiration * Flip off * Only require alerting and actions if enabled * Support date formating and timezones in the alert UI messages, support ccs better * Fix status tests * Fix up front end tests * Fix linting, and switch this back * Add this back in so legacy alerts continue to work * Fix type issues * Handle CCS better * Code cleanup * Fix type issues * Flip this off, and fix test * Moved the email address config to advanced settings, but need help with test failures and typescript * Fix issue with task manager * Deprecate email_address * Use any until we can figure out this TS issue * Fix type issue * More tests * Fix mocha tests * Use mock instead of any * I'm not sure why these changed... * Provide timezone in moment usage in tests for consistency * Fix type issue * Change how we get dateFormat and timezone * Change where we calculate the dates to show in the alerts UI * Show deprecation warning based on the feature toggle * Ensure we are using UTC * PR feedback * Only add this if the feature flag is enabled * Fix tests * Ensure we only attempt to look this up if the feature flag is enabled Co-authored-by: Elastic Machine --- .../common/{constants.js => constants.ts} | 42 ++ .../legacy/plugins/monitoring/deprecations.js | 20 +- x-pack/legacy/plugins/monitoring/index.js | 12 +- .../alerts/__snapshots__/status.test.tsx.snap | 70 +++ .../__snapshots__/configuration.test.tsx.snap | 120 +++++ .../__snapshots__/step1.test.tsx.snap | 297 ++++++++++++ .../__snapshots__/step2.test.tsx.snap | 49 ++ .../__snapshots__/step3.test.tsx.snap | 95 ++++ .../configuration/configuration.test.tsx | 147 ++++++ .../alerts/configuration/configuration.tsx | 193 ++++++++ .../components/alerts/configuration/index.ts | 7 + .../alerts/configuration/step1.test.tsx | 338 +++++++++++++ .../components/alerts/configuration/step1.tsx | 334 +++++++++++++ .../alerts/configuration/step2.test.tsx | 51 ++ .../components/alerts/configuration/step2.tsx | 38 ++ .../alerts/configuration/step3.test.tsx | 48 ++ .../components/alerts/configuration/step3.tsx | 47 ++ .../components/alerts/manage_email_action.tsx | 301 ++++++++++++ .../public/components/alerts/status.test.tsx | 81 ++++ .../public/components/alerts/status.tsx | 203 ++++++++ .../cluster/overview/alerts_panel.js | 62 ++- .../components/cluster/overview/index.js | 14 +- .../plugins/monitoring/public/jest.helpers.ts | 36 ++ ...rror_handler.js => ajax_error_handler.tsx} | 7 +- .../monitoring/public/lib/form_validation.ts | 48 ++ .../monitoring/public/lib/setup_mode.test.js | 8 +- .../lib/{setup_mode.js => setup_mode.tsx} | 62 ++- .../monitoring/public/views/alerts/index.js | 2 +- .../public/views/cluster/overview/index.js | 21 +- .../server/alerts/license_expiration.test.ts | 453 ++++++++++++++++++ .../server/alerts/license_expiration.ts | 162 +++++++ .../monitoring/server/alerts/types.d.ts | 45 ++ .../lib/alerts/fetch_available_ccs.test.ts | 36 ++ .../server/lib/alerts/fetch_available_ccs.ts | 19 + .../server/lib/alerts/fetch_clusters.test.ts | 33 ++ .../server/lib/alerts/fetch_clusters.ts | 52 ++ .../fetch_default_email_address.test.ts | 17 + .../lib/alerts/fetch_default_email_address.ts | 13 + .../server/lib/alerts/fetch_licenses.test.ts | 105 ++++ .../server/lib/alerts/fetch_licenses.ts | 67 +++ .../server/lib/alerts/fetch_status.ts | 87 ++++ .../lib/alerts/get_ccs_index_pattern.test.ts | 24 + .../lib/alerts/get_ccs_index_pattern.ts | 13 + .../lib/alerts/license_expiration.lib.test.ts | 55 +++ .../lib/alerts/license_expiration.lib.ts | 58 +++ .../lib/cluster/get_clusters_from_request.js | 32 +- .../monitoring/server/lib/get_date_format.js | 9 + .../setup/collection/get_collection_status.js | 1 - .../plugins/monitoring/server/plugin.js | 39 +- .../server/routes/api/v1/alerts/alerts.js | 89 ++++ .../server/routes/api/v1/alerts/index.js | 53 +- .../routes/api/v1/alerts/legacy_alerts.js | 57 +++ .../monitoring/server/routes/api/v1/ui.js | 2 +- .../legacy/plugins/monitoring/ui_exports.js | 72 ++- x-pack/plugins/actions/common/types.ts | 7 + 55 files changed, 4223 insertions(+), 130 deletions(-) rename x-pack/legacy/plugins/monitoring/common/{constants.js => constants.ts} (85%) create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/__snapshots__/status.test.tsx.snap create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/configuration.test.tsx.snap create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step1.test.tsx.snap create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step2.test.tsx.snap create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step3.test.tsx.snap create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.test.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/index.ts create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.test.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.test.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.test.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx create mode 100644 x-pack/legacy/plugins/monitoring/public/jest.helpers.ts rename x-pack/legacy/plugins/monitoring/public/lib/{ajax_error_handler.js => ajax_error_handler.tsx} (94%) create mode 100644 x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts rename x-pack/legacy/plugins/monitoring/public/lib/{setup_mode.js => setup_mode.tsx} (76%) create mode 100644 x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.test.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.test.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_status.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.test.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts create mode 100644 x-pack/legacy/plugins/monitoring/server/lib/get_date_format.js create mode 100644 x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js create mode 100644 x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js diff --git a/x-pack/legacy/plugins/monitoring/common/constants.js b/x-pack/legacy/plugins/monitoring/common/constants.ts similarity index 85% rename from x-pack/legacy/plugins/monitoring/common/constants.js rename to x-pack/legacy/plugins/monitoring/common/constants.ts index ff16b0e9c5167..53764f592dc15 100644 --- a/x-pack/legacy/plugins/monitoring/common/constants.js +++ b/x-pack/legacy/plugins/monitoring/common/constants.ts @@ -233,3 +233,45 @@ export const REPORTING_SYSTEM_ID = 'reporting'; * @type {Number} */ export const TELEMETRY_COLLECTION_INTERVAL = 86400000; + +/** + * We want to slowly rollout the migration from watcher-based cluster alerts to + * kibana alerts and we only want to enable the kibana alerts once all + * watcher-based cluster alerts have been migrated so this flag will serve + * as the only way to see the new UI and actually run Kibana alerts. It will + * be false until all alerts have been migrated, then it will be removed + */ +export const KIBANA_ALERTING_ENABLED = false; + +/** + * The prefix for all alert types used by monitoring + */ +export const ALERT_TYPE_PREFIX = 'monitoring_'; + +/** + * This is the alert type id for the license expiration alert + */ +export const ALERT_TYPE_LICENSE_EXPIRATION = `${ALERT_TYPE_PREFIX}alert_type_license_expiration`; + +/** + * A listing of all alert types + */ +export const ALERT_TYPES = [ALERT_TYPE_LICENSE_EXPIRATION]; + +/** + * Matches the id for the built-in in email action type + * See x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts + */ +export const ALERT_ACTION_TYPE_EMAIL = '.email'; + +/** + * The number of alerts that have been migrated + */ +export const NUMBER_OF_MIGRATED_ALERTS = 1; + +/** + * The advanced settings config name for the email address + */ +export const MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS = 'monitoring:alertingEmailAddress'; + +export const ALERT_EMAIL_SERVICES = ['gmail', 'hotmail', 'icloud', 'outlook365', 'ses', 'yahoo']; diff --git a/x-pack/legacy/plugins/monitoring/deprecations.js b/x-pack/legacy/plugins/monitoring/deprecations.js index 6e35e86dd9d71..ae8650fd3b26a 100644 --- a/x-pack/legacy/plugins/monitoring/deprecations.js +++ b/x-pack/legacy/plugins/monitoring/deprecations.js @@ -5,7 +5,7 @@ */ import { get } from 'lodash'; -import { CLUSTER_ALERTS_ADDRESS_CONFIG_KEY } from './common/constants'; +import { CLUSTER_ALERTS_ADDRESS_CONFIG_KEY, KIBANA_ALERTING_ENABLED } from './common/constants'; /** * Re-writes deprecated user-defined config settings and logs warnings as a @@ -21,10 +21,20 @@ export const deprecations = () => { const clusterAlertsEnabled = get(settings, 'cluster_alerts.enabled'); const emailNotificationsEnabled = clusterAlertsEnabled && get(settings, 'cluster_alerts.email_notifications.enabled'); - if (emailNotificationsEnabled && !get(settings, CLUSTER_ALERTS_ADDRESS_CONFIG_KEY)) { - log( - `Config key "${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}" will be required for email notifications to work in 7.0."` - ); + if (emailNotificationsEnabled) { + if (KIBANA_ALERTING_ENABLED) { + if (get(settings, CLUSTER_ALERTS_ADDRESS_CONFIG_KEY)) { + log( + `Config key "${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}" is deprecated. Please configure the email adddress through the Stack Monitoring UI instead."` + ); + } + } else { + if (!get(settings, CLUSTER_ALERTS_ADDRESS_CONFIG_KEY)) { + log( + `Config key "${CLUSTER_ALERTS_ADDRESS_CONFIG_KEY}" will be required for email notifications to work in 7.0."` + ); + } + } } }, (settings, log) => { diff --git a/x-pack/legacy/plugins/monitoring/index.js b/x-pack/legacy/plugins/monitoring/index.js index ca595836133c2..ade172f527dab 100644 --- a/x-pack/legacy/plugins/monitoring/index.js +++ b/x-pack/legacy/plugins/monitoring/index.js @@ -10,15 +10,20 @@ import { deprecations } from './deprecations'; import { getUiExports } from './ui_exports'; import { Plugin } from './server/plugin'; import { initInfraSource } from './server/lib/logs/init_infra_source'; +import { KIBANA_ALERTING_ENABLED } from './common/constants'; /** * Invokes plugin modules to instantiate the Monitoring plugin for Kibana * @param kibana {Object} Kibana plugin instance * @return {Object} Monitoring UI Kibana plugin object */ +const deps = ['kibana', 'elasticsearch', 'xpack_main']; +if (KIBANA_ALERTING_ENABLED) { + deps.push(...['alerting', 'actions']); +} export const monitoring = kibana => new kibana.Plugin({ - require: ['kibana', 'elasticsearch', 'xpack_main'], + require: deps, id: 'monitoring', configPrefix: 'monitoring', publicDir: resolve(__dirname, 'public'), @@ -59,6 +64,7 @@ export const monitoring = kibana => }), injectUiAppVars: server.injectUiAppVars, log: (...args) => server.log(...args), + logger: server.newPlatform.coreContext.logger, getOSInfo: server.getOSInfo, events: { on: (...args) => server.events.on(...args), @@ -73,11 +79,13 @@ export const monitoring = kibana => xpack_main: server.plugins.xpack_main, elasticsearch: server.plugins.elasticsearch, infra: server.plugins.infra, + alerting: server.plugins.alerting, usageCollection, licensing, }; - new Plugin().setup(serverFacade, plugins); + const plugin = new Plugin(); + plugin.setup(serverFacade, plugins); }, config, deprecations, diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/__snapshots__/status.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/__snapshots__/status.test.tsx.snap new file mode 100644 index 0000000000000..4cf1f4df2eb2e --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/__snapshots__/status.test.tsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Status should render a flyout when clicking the link 1`] = ` + + + +

+ Monitoring alerts +

+
+ +

+ Configure an email server and email address to receive alerts. +

+
+
+ + + +
+`; + +exports[`Status should render a success message if all alerts have been migrated and in setup mode 1`] = ` + +

+ + Want to make changes? Click here. + +

+
+`; + +exports[`Status should render without setup mode 1`] = ` + + +

+ + Migrate cluster alerts to our new alerting platform. + +

+
+ +
+`; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/configuration.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/configuration.test.tsx.snap new file mode 100644 index 0000000000000..f044e001700c5 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/configuration.test.tsx.snap @@ -0,0 +1,120 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Configuration shallow view should render step 1 1`] = ` + + + Create new email action... + , + "inputDisplay": + Create new email action... + , + "value": "__new__", + }, + ] + } + valueOfSelected="" + /> + +`; + +exports[`Configuration shallow view should render step 2 1`] = ` + + + + + +`; + +exports[`Configuration shallow view should render step 3 1`] = ` + + + Save + + +`; + +exports[`Configuration should render high level steps 1`] = ` +
+ + + + + + + + + +
+`; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step1.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step1.test.tsx.snap new file mode 100644 index 0000000000000..fa03769ea3d09 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step1.test.tsx.snap @@ -0,0 +1,297 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Step1 creating should render a create form 1`] = ` + + + + + +`; + +exports[`Step1 editing should allow for editing 1`] = ` + + +

+ Edit the action below. +

+
+ + +
+`; + +exports[`Step1 should render normally 1`] = ` + + + From: , Service: + , + "inputDisplay": + From: , Service: + , + "value": "1", + }, + Object { + "dropdownDisplay": + Create new email action... + , + "inputDisplay": + Create new email action... + , + "value": "__new__", + }, + ] + } + valueOfSelected="1" + /> + + + + + Edit + + + + + Test + + + + + Delete + + + + +`; + +exports[`Step1 testing should should a tooltip if there is no email address 1`] = ` + + + Test + + +`; + +exports[`Step1 testing should show a failed test error 1`] = ` + + + From: , Service: + , + "inputDisplay": + From: , Service: + , + "value": "1", + }, + Object { + "dropdownDisplay": + Create new email action... + , + "inputDisplay": + Create new email action... + , + "value": "__new__", + }, + ] + } + valueOfSelected="1" + /> + + + + + Edit + + + + + Test + + + + + Delete + + + + + +

+ Very detailed error message +

+
+
+`; + +exports[`Step1 testing should show a successful test 1`] = ` + + + From: , Service: + , + "inputDisplay": + From: , Service: + , + "value": "1", + }, + Object { + "dropdownDisplay": + Create new email action... + , + "inputDisplay": + Create new email action... + , + "value": "__new__", + }, + ] + } + valueOfSelected="1" + /> + + + + + Edit + + + + + Test + + + + + Delete + + + + + +

+ Looks good on our end! +

+
+
+`; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step2.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step2.test.tsx.snap new file mode 100644 index 0000000000000..bac183618b491 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step2.test.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Step2 should render normally 1`] = ` + + + + + +`; + +exports[`Step2 should show form errors 1`] = ` + + + + + +`; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step3.test.tsx.snap b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step3.test.tsx.snap new file mode 100644 index 0000000000000..ed15ae9a9cff7 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/__snapshots__/step3.test.tsx.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Step3 should render normally 1`] = ` + + + Save + + +`; + +exports[`Step3 should show a disabled state 1`] = ` + + + Save + + +`; + +exports[`Step3 should show a saving state 1`] = ` + + + Save + + +`; + +exports[`Step3 should show an error 1`] = ` + + +

+ Test error +

+
+ + + Save + +
+`; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.test.tsx new file mode 100644 index 0000000000000..6b7e2391e0301 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.test.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mockUseEffects } from '../../../jest.helpers'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { kfetch } from 'ui/kfetch'; +import { AlertsConfiguration, AlertsConfigurationProps } from './configuration'; + +jest.mock('ui/kfetch', () => ({ + kfetch: jest.fn(), +})); + +const defaultProps: AlertsConfigurationProps = { + emailAddress: 'test@elastic.co', + onDone: jest.fn(), +}; + +describe('Configuration', () => { + it('should render high level steps', () => { + const component = shallow(); + expect(component.find('EuiSteps').shallow()).toMatchSnapshot(); + }); + + function getStep(component: ShallowWrapper, index: number) { + return component + .find('EuiSteps') + .shallow() + .find('EuiStep') + .at(index) + .children() + .shallow(); + } + + describe('shallow view', () => { + it('should render step 1', () => { + const component = shallow(); + const stepOne = getStep(component, 0); + expect(stepOne).toMatchSnapshot(); + }); + + it('should render step 2', () => { + const component = shallow(); + const stepTwo = getStep(component, 1); + expect(stepTwo).toMatchSnapshot(); + }); + + it('should render step 3', () => { + const component = shallow(); + const stepThree = getStep(component, 2); + expect(stepThree).toMatchSnapshot(); + }); + }); + + describe('selected action', () => { + const actionId = 'a123b'; + let component: ShallowWrapper; + beforeEach(async () => { + mockUseEffects(2); + + (kfetch as jest.Mock).mockImplementation(() => { + return { + data: [ + { + actionTypeId: '.email', + id: actionId, + config: {}, + }, + ], + }; + }); + + component = shallow(); + }); + + it('reflect in Step1', async () => { + const steps = component.find('EuiSteps').dive(); + expect( + steps + .find('EuiStep') + .at(0) + .prop('title') + ).toBe('Select email action'); + expect(steps.find('Step1').prop('selectedEmailActionId')).toBe(actionId); + }); + + it('should enable Step2', async () => { + const steps = component.find('EuiSteps').dive(); + expect(steps.find('Step2').prop('isDisabled')).toBe(false); + }); + + it('should enable Step3', async () => { + const steps = component.find('EuiSteps').dive(); + expect(steps.find('Step3').prop('isDisabled')).toBe(false); + }); + }); + + describe('edit action', () => { + let component: ShallowWrapper; + beforeEach(async () => { + (kfetch as jest.Mock).mockImplementation(() => { + return { + data: [], + }; + }); + + component = shallow(); + }); + + it('disable Step2', async () => { + const steps = component.find('EuiSteps').dive(); + expect(steps.find('Step2').prop('isDisabled')).toBe(true); + }); + + it('disable Step3', async () => { + const steps = component.find('EuiSteps').dive(); + expect(steps.find('Step3').prop('isDisabled')).toBe(true); + }); + }); + + describe('no email address', () => { + let component: ShallowWrapper; + beforeEach(async () => { + (kfetch as jest.Mock).mockImplementation(() => { + return { + data: [ + { + actionTypeId: '.email', + id: 'actionId', + config: {}, + }, + ], + }; + }); + + component = shallow(); + }); + + it('should disable Step3', async () => { + const steps = component.find('EuiSteps').dive(); + expect(steps.find('Step3').prop('isDisabled')).toBe(true); + }); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.tsx new file mode 100644 index 0000000000000..0933cd22db7c9 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/configuration.tsx @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, { ReactNode } from 'react'; +import { kfetch } from 'ui/kfetch'; +import { EuiSteps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionResult } from '../../../../../../../plugins/actions/common'; +import { ALERT_ACTION_TYPE_EMAIL } from '../../../../common/constants'; +import { getMissingFieldErrors } from '../../../lib/form_validation'; +import { Step1 } from './step1'; +import { Step2 } from './step2'; +import { Step3 } from './step3'; + +export interface AlertsConfigurationProps { + emailAddress: string; + onDone: Function; +} + +export interface StepResult { + title: string; + children: ReactNode; + status: any; +} + +export interface AlertsConfigurationForm { + email: string | null; +} + +export const NEW_ACTION_ID = '__new__'; + +export const AlertsConfiguration: React.FC = ( + props: AlertsConfigurationProps +) => { + const { onDone } = props; + + const [emailActions, setEmailActions] = React.useState([]); + const [selectedEmailActionId, setSelectedEmailActionId] = React.useState(''); + const [editAction, setEditAction] = React.useState(null); + const [emailAddress, setEmailAddress] = React.useState(props.emailAddress); + const [formErrors, setFormErrors] = React.useState({ email: null }); + const [showFormErrors, setShowFormErrors] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(''); + + React.useEffect(() => { + async function fetchData() { + await fetchEmailActions(); + } + + fetchData(); + }, []); + + React.useEffect(() => { + setFormErrors(getMissingFieldErrors({ email: emailAddress }, { email: '' })); + }, [emailAddress]); + + async function fetchEmailActions() { + const kibanaActions = await kfetch({ + method: 'GET', + pathname: `/api/action/_find`, + }); + + const actions = kibanaActions.data.filter( + (action: ActionResult) => action.actionTypeId === ALERT_ACTION_TYPE_EMAIL + ); + if (actions.length > 0) { + setSelectedEmailActionId(actions[0].id); + } else { + setSelectedEmailActionId(NEW_ACTION_ID); + } + setEmailActions(actions); + } + + async function save() { + if (emailAddress.length === 0) { + setShowFormErrors(true); + return; + } + setIsSaving(true); + setShowFormErrors(false); + + try { + await kfetch({ + method: 'POST', + pathname: `/api/monitoring/v1/alerts`, + body: JSON.stringify({ selectedEmailActionId, emailAddress }), + }); + } catch (err) { + setIsSaving(false); + setSaveError( + err?.body?.message || + i18n.translate('xpack.monitoring.alerts.configuration.unknownError', { + defaultMessage: 'Something went wrong. Please consult the server logs.', + }) + ); + return; + } + + onDone(); + } + + function isStep2Disabled() { + return isStep2AndStep3Disabled(); + } + + function isStep3Disabled() { + return isStep2AndStep3Disabled() || !emailAddress || emailAddress.length === 0; + } + + function isStep2AndStep3Disabled() { + return !!editAction || !selectedEmailActionId || selectedEmailActionId === NEW_ACTION_ID; + } + + function getStep2Status() { + const isDisabled = isStep2AndStep3Disabled(); + + if (isDisabled) { + return 'disabled' as const; + } + + if (emailAddress && emailAddress.length) { + return 'complete' as const; + } + + return 'incomplete' as const; + } + + function getStep1Status() { + if (editAction) { + return 'incomplete' as const; + } + + return selectedEmailActionId ? ('complete' as const) : ('incomplete' as const); + } + + const steps = [ + { + title: emailActions.length + ? i18n.translate('xpack.monitoring.alerts.configuration.selectEmailAction', { + defaultMessage: 'Select email action', + }) + : i18n.translate('xpack.monitoring.alerts.configuration.createEmailAction', { + defaultMessage: 'Create email action', + }), + children: ( + await fetchEmailActions()} + emailActions={emailActions} + selectedEmailActionId={selectedEmailActionId} + setSelectedEmailActionId={setSelectedEmailActionId} + emailAddress={emailAddress} + editAction={editAction} + setEditAction={setEditAction} + /> + ), + status: getStep1Status(), + }, + { + title: i18n.translate('xpack.monitoring.alerts.configuration.setEmailAddress', { + defaultMessage: 'Set the email to receive alerts', + }), + status: getStep2Status(), + children: ( + + ), + }, + { + title: i18n.translate('xpack.monitoring.alerts.configuration.confirm', { + defaultMessage: 'Confirm and save', + }), + status: getStep2Status(), + children: ( + + ), + }, + ]; + + return ( +
+ +
+ ); +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/index.ts b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/index.ts new file mode 100644 index 0000000000000..7a96c6e324ab3 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/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 { AlertsConfiguration } from './configuration'; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.test.tsx new file mode 100644 index 0000000000000..650294c29e9a5 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.test.tsx @@ -0,0 +1,338 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { omit, pick } from 'lodash'; +import '../../../jest.helpers'; +import { shallow } from 'enzyme'; +import { GetStep1Props } from './step1'; +import { EmailActionData } from '../manage_email_action'; +import { ALERT_ACTION_TYPE_EMAIL } from '../../../../common/constants'; + +let Step1: React.FC; +let NEW_ACTION_ID: string; + +function setModules() { + Step1 = require('./step1').Step1; + NEW_ACTION_ID = require('./configuration').NEW_ACTION_ID; +} + +describe('Step1', () => { + const emailActions = [ + { + id: '1', + actionTypeId: '1abc', + name: 'Testing', + config: {}, + }, + ]; + const selectedEmailActionId = emailActions[0].id; + const setSelectedEmailActionId = jest.fn(); + const emailAddress = 'test@test.com'; + const editAction = null; + const setEditAction = jest.fn(); + const onActionDone = jest.fn(); + + const defaultProps: GetStep1Props = { + onActionDone, + emailActions, + selectedEmailActionId, + setSelectedEmailActionId, + emailAddress, + editAction, + setEditAction, + }; + + beforeEach(() => { + jest.isolateModules(() => { + jest.doMock('ui/kfetch', () => ({ + kfetch: () => { + return {}; + }, + })); + setModules(); + }); + }); + + it('should render normally', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + + describe('creating', () => { + it('should render a create form', () => { + const customProps = { + emailActions: [], + selectedEmailActionId: NEW_ACTION_ID, + }; + + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + + it('should render the select box if at least one action exists', () => { + const customProps = { + emailActions: [ + { + id: 'foo', + actionTypeId: '.email', + name: '', + config: {}, + }, + ], + selectedEmailActionId: NEW_ACTION_ID, + }; + + const component = shallow(); + expect(component.find('EuiSuperSelect').exists()).toBe(true); + }); + + it('should send up the create to the server', async () => { + const kfetch = jest.fn().mockImplementation(() => {}); + jest.isolateModules(() => { + jest.doMock('ui/kfetch', () => ({ + kfetch, + })); + setModules(); + }); + + const customProps = { + emailActions: [], + selectedEmailActionId: NEW_ACTION_ID, + }; + + const component = shallow(); + + const data: EmailActionData = { + service: 'gmail', + host: 'smtp.gmail.com', + port: 465, + secure: true, + from: 'test@test.com', + user: 'user@user.com', + password: 'password', + }; + + const createEmailAction: (data: EmailActionData) => void = component + .find('ManageEmailAction') + .prop('createEmailAction'); + createEmailAction(data); + + expect(kfetch).toHaveBeenCalledWith({ + method: 'POST', + pathname: `/api/action`, + body: JSON.stringify({ + name: 'Email action for Stack Monitoring alerts', + actionTypeId: ALERT_ACTION_TYPE_EMAIL, + config: omit(data, ['user', 'password']), + secrets: pick(data, ['user', 'password']), + }), + }); + }); + }); + + describe('editing', () => { + it('should allow for editing', () => { + const customProps = { + editAction: emailActions[0], + }; + + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); + + it('should send up the edit to the server', async () => { + const kfetch = jest.fn().mockImplementation(() => {}); + jest.isolateModules(() => { + jest.doMock('ui/kfetch', () => ({ + kfetch, + })); + setModules(); + }); + + const customProps = { + editAction: emailActions[0], + }; + + const component = shallow(); + + const data: EmailActionData = { + service: 'gmail', + host: 'smtp.gmail.com', + port: 465, + secure: true, + from: 'test@test.com', + user: 'user@user.com', + password: 'password', + }; + + const createEmailAction: (data: EmailActionData) => void = component + .find('ManageEmailAction') + .prop('createEmailAction'); + createEmailAction(data); + + expect(kfetch).toHaveBeenCalledWith({ + method: 'PUT', + pathname: `/api/action/${emailActions[0].id}`, + body: JSON.stringify({ + name: emailActions[0].name, + config: omit(data, ['user', 'password']), + secrets: pick(data, ['user', 'password']), + }), + }); + }); + }); + + describe('testing', () => { + it('should allow for testing', async () => { + jest.isolateModules(() => { + jest.doMock('ui/kfetch', () => ({ + kfetch: jest.fn().mockImplementation(arg => { + if (arg.pathname === '/api/action/1/_execute') { + return { status: 'ok' }; + } + return {}; + }), + })); + setModules(); + }); + + const component = shallow(); + + expect( + component + .find('EuiButton') + .at(1) + .prop('isLoading') + ).toBe(false); + component + .find('EuiButton') + .at(1) + .simulate('click'); + expect( + component + .find('EuiButton') + .at(1) + .prop('isLoading') + ).toBe(true); + await component.update(); + expect( + component + .find('EuiButton') + .at(1) + .prop('isLoading') + ).toBe(false); + }); + + it('should show a successful test', async () => { + jest.isolateModules(() => { + jest.doMock('ui/kfetch', () => ({ + kfetch: (arg: any) => { + if (arg.pathname === '/api/action/1/_execute') { + return { status: 'ok' }; + } + return {}; + }, + })); + setModules(); + }); + + const component = shallow(); + + component + .find('EuiButton') + .at(1) + .simulate('click'); + await component.update(); + expect(component).toMatchSnapshot(); + }); + + it('should show a failed test error', async () => { + jest.isolateModules(() => { + jest.doMock('ui/kfetch', () => ({ + kfetch: (arg: any) => { + if (arg.pathname === '/api/action/1/_execute') { + return { message: 'Very detailed error message' }; + } + return {}; + }, + })); + setModules(); + }); + + const component = shallow(); + + component + .find('EuiButton') + .at(1) + .simulate('click'); + await component.update(); + expect(component).toMatchSnapshot(); + }); + + it('should not allow testing if there is no email address', () => { + const customProps = { + emailAddress: '', + }; + const component = shallow(); + expect( + component + .find('EuiButton') + .at(1) + .prop('isDisabled') + ).toBe(true); + }); + + it('should should a tooltip if there is no email address', () => { + const customProps = { + emailAddress: '', + }; + const component = shallow(); + expect(component.find('EuiToolTip')).toMatchSnapshot(); + }); + }); + + describe('deleting', () => { + it('should send up the delete to the server', async () => { + const kfetch = jest.fn().mockImplementation(() => {}); + jest.isolateModules(() => { + jest.doMock('ui/kfetch', () => ({ + kfetch, + })); + setModules(); + }); + + const customProps = { + setSelectedEmailActionId: jest.fn(), + onActionDone: jest.fn(), + }; + const component = shallow(); + + await component + .find('EuiButton') + .at(2) + .simulate('click'); + await component.update(); + + expect(kfetch).toHaveBeenCalledWith({ + method: 'DELETE', + pathname: `/api/action/${emailActions[0].id}`, + }); + + expect(customProps.setSelectedEmailActionId).toHaveBeenCalledWith(''); + expect(customProps.onActionDone).toHaveBeenCalled(); + expect( + component + .find('EuiButton') + .at(2) + .prop('isLoading') + ).toBe(false); + }); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.tsx new file mode 100644 index 0000000000000..fc051a68e29f3 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step1.tsx @@ -0,0 +1,334 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { + EuiText, + EuiSpacer, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiSuperSelect, + EuiToolTip, + EuiCallOut, +} from '@elastic/eui'; +import { kfetch } from 'ui/kfetch'; +import { omit, pick } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { ActionResult } from '../../../../../../../plugins/actions/common'; +import { ManageEmailAction, EmailActionData } from '../manage_email_action'; +import { ALERT_ACTION_TYPE_EMAIL } from '../../../../common/constants'; +import { NEW_ACTION_ID } from './configuration'; + +export interface GetStep1Props { + onActionDone: () => Promise; + emailActions: ActionResult[]; + selectedEmailActionId: string; + setSelectedEmailActionId: (id: string) => void; + emailAddress: string; + editAction: ActionResult | null; + setEditAction: (action: ActionResult | null) => void; +} + +export const Step1: React.FC = (props: GetStep1Props) => { + const [isTesting, setIsTesting] = React.useState(false); + const [isDeleting, setIsDeleting] = React.useState(false); + const [testingStatus, setTestingStatus] = React.useState(null); + const [fullTestingError, setFullTestingError] = React.useState(''); + + async function createEmailAction(data: EmailActionData) { + if (props.editAction) { + await kfetch({ + method: 'PUT', + pathname: `/api/action/${props.editAction.id}`, + body: JSON.stringify({ + name: props.editAction.name, + config: omit(data, ['user', 'password']), + secrets: pick(data, ['user', 'password']), + }), + }); + props.setEditAction(null); + } else { + await kfetch({ + method: 'POST', + pathname: '/api/action', + body: JSON.stringify({ + name: i18n.translate('xpack.monitoring.alerts.configuration.emailAction.name', { + defaultMessage: 'Email action for Stack Monitoring alerts', + }), + actionTypeId: ALERT_ACTION_TYPE_EMAIL, + config: omit(data, ['user', 'password']), + secrets: pick(data, ['user', 'password']), + }), + }); + } + + await props.onActionDone(); + } + + async function deleteEmailAction(id: string) { + setIsDeleting(true); + + await kfetch({ + method: 'DELETE', + pathname: `/api/action/${id}`, + }); + + if (props.editAction && props.editAction.id === id) { + props.setEditAction(null); + } + if (props.selectedEmailActionId === id) { + props.setSelectedEmailActionId(''); + } + await props.onActionDone(); + setIsDeleting(false); + setTestingStatus(null); + } + + async function testEmailAction() { + setIsTesting(true); + setTestingStatus(null); + + const params = { + subject: 'Kibana alerting test configuration', + message: `This is a test for the configured email action for Kibana alerting.`, + to: [props.emailAddress], + }; + + const result = await kfetch({ + method: 'POST', + pathname: `/api/action/${props.selectedEmailActionId}/_execute`, + body: JSON.stringify({ params }), + }); + if (result.status === 'ok') { + setTestingStatus(true); + } else { + setTestingStatus(false); + setFullTestingError(result.message); + } + setIsTesting(false); + } + + function getTestButton() { + const isTestingDisabled = !props.emailAddress || props.emailAddress.length === 0; + const testBtn = ( + + {i18n.translate('xpack.monitoring.alerts.configuration.testConfiguration.buttonText', { + defaultMessage: 'Test', + })} + + ); + + if (isTestingDisabled) { + return ( + + {testBtn} + + ); + } + + return testBtn; + } + + if (props.editAction) { + return ( + + +

+ {i18n.translate('xpack.monitoring.alerts.configuration.step1.editAction', { + defaultMessage: 'Edit the action below.', + })} +

+
+ + await createEmailAction(data)} + cancel={() => props.setEditAction(null)} + isNew={false} + action={props.editAction} + /> +
+ ); + } + + const newAction = ( + + {i18n.translate('xpack.monitoring.alerts.configuration.newActionDropdownDisplay', { + defaultMessage: 'Create new email action...', + })} + + ); + + const options = [ + ...props.emailActions.map(action => { + const actionLabel = i18n.translate( + 'xpack.monitoring.alerts.configuration.selectAction.inputDisplay', + { + defaultMessage: 'From: {from}, Service: {service}', + values: { + service: action.config.service, + from: action.config.from, + }, + } + ); + + return { + value: action.id, + inputDisplay: {actionLabel}, + dropdownDisplay: {actionLabel}, + }; + }), + { + value: NEW_ACTION_ID, + inputDisplay: newAction, + dropdownDisplay: newAction, + }, + ]; + + let selectBox: React.ReactNode | null = ( + props.setSelectedEmailActionId(id)} + hasDividers + /> + ); + let createNew = null; + if (props.selectedEmailActionId === NEW_ACTION_ID) { + createNew = ( + + await createEmailAction(data)} + isNew={true} + /> + + ); + + // If there are no actions, do not show the select box as there are no choices + if (props.emailActions.length === 0) { + selectBox = null; + } else { + // Otherwise, add a spacer + selectBox = ( + + {selectBox} + + + ); + } + } + + let manageConfiguration = null; + const selectedEmailAction = props.emailActions.find( + action => action.id === props.selectedEmailActionId + ); + + if ( + props.selectedEmailActionId !== NEW_ACTION_ID && + props.selectedEmailActionId && + selectedEmailAction + ) { + let testingStatusUi = null; + if (testingStatus === true) { + testingStatusUi = ( + + + +

+ {i18n.translate('xpack.monitoring.alerts.configuration.testConfiguration.success', { + defaultMessage: 'Looks good on our end!', + })} +

+
+
+ ); + } else if (testingStatus === false) { + testingStatusUi = ( + + + +

{fullTestingError}

+
+
+ ); + } + + manageConfiguration = ( + + + + + { + const editAction = + props.emailActions.find(action => action.id === props.selectedEmailActionId) || + null; + props.setEditAction(editAction); + }} + > + {i18n.translate( + 'xpack.monitoring.alerts.configuration.editConfiguration.buttonText', + { + defaultMessage: 'Edit', + } + )} + + + {getTestButton()} + + deleteEmailAction(props.selectedEmailActionId)} + isLoading={isDeleting} + > + {i18n.translate( + 'xpack.monitoring.alerts.configuration.deleteConfiguration.buttonText', + { + defaultMessage: 'Delete', + } + )} + + + + {testingStatusUi} + + ); + } + + return ( + + {selectBox} + {manageConfiguration} + {createNew} + + ); +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.test.tsx new file mode 100644 index 0000000000000..14e3cb078f9cc --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.test.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 React from 'react'; +import '../../../jest.helpers'; +import { shallow } from 'enzyme'; +import { Step2, GetStep2Props } from './step2'; + +describe('Step2', () => { + const defaultProps: GetStep2Props = { + emailAddress: 'test@test.com', + setEmailAddress: jest.fn(), + showFormErrors: false, + formErrors: { email: null }, + isDisabled: false, + }; + + it('should render normally', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('should set the email address properly', () => { + const newEmail = 'email@email.com'; + const component = shallow(); + component.find('EuiFieldText').simulate('change', { target: { value: newEmail } }); + expect(defaultProps.setEmailAddress).toHaveBeenCalledWith(newEmail); + }); + + it('should show form errors', () => { + const customProps = { + showFormErrors: true, + formErrors: { + email: 'This is required', + }, + }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('should disable properly', () => { + const customProps = { + isDisabled: true, + }; + const component = shallow(); + expect(component.find('EuiFieldText').prop('disabled')).toBe(true); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.tsx new file mode 100644 index 0000000000000..974dd8513d231 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step2.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiForm, EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { AlertsConfigurationForm } from './configuration'; + +export interface GetStep2Props { + emailAddress: string; + setEmailAddress: (email: string) => void; + showFormErrors: boolean; + formErrors: AlertsConfigurationForm; + isDisabled: boolean; +} + +export const Step2: React.FC = (props: GetStep2Props) => { + return ( + + + props.setEmailAddress(e.target.value)} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.test.tsx new file mode 100644 index 0000000000000..9b1304c42a507 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 '../../../jest.helpers'; +import { shallow } from 'enzyme'; +import { Step3 } from './step3'; + +describe('Step3', () => { + const defaultProps = { + isSaving: false, + isDisabled: false, + save: jest.fn(), + error: null, + }; + + it('should render normally', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('should save properly', () => { + const component = shallow(); + component.find('EuiButton').simulate('click'); + expect(defaultProps.save).toHaveBeenCalledWith(); + }); + + it('should show a saving state', () => { + const customProps = { isSaving: true }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('should show a disabled state', () => { + const customProps = { isDisabled: true }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('should show an error', () => { + const customProps = { error: 'Test error' }; + const component = shallow(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.tsx new file mode 100644 index 0000000000000..80acb8992cbc1 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/configuration/step3.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { EuiButton, EuiSpacer, EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export interface GetStep3Props { + isSaving: boolean; + isDisabled: boolean; + save: () => void; + error: string | null; +} + +export const Step3: React.FC = (props: GetStep3Props) => { + let errorUi = null; + if (props.error) { + errorUi = ( + + +

{props.error}

+
+ +
+ ); + } + + return ( + + {errorUi} + + {i18n.translate('xpack.monitoring.alerts.configuration.save', { + defaultMessage: 'Save', + })} + + + ); +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx new file mode 100644 index 0000000000000..2bd9804795cb5 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/manage_email_action.tsx @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { + EuiForm, + EuiFormRow, + EuiFieldText, + EuiLink, + EuiSpacer, + EuiFieldNumber, + EuiFieldPassword, + EuiSwitch, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSuperSelect, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionResult } from '../../../../../../plugins/actions/common'; +import { getMissingFieldErrors, hasErrors, getRequiredFieldError } from '../../lib/form_validation'; +import { ALERT_EMAIL_SERVICES } from '../../../common/constants'; + +export interface EmailActionData { + service: string; + host: string; + port?: number; + secure: boolean; + from: string; + user: string; + password: string; +} + +interface ManageActionModalProps { + createEmailAction: (handler: EmailActionData) => void; + cancel?: () => void; + isNew: boolean; + action?: ActionResult | null; +} + +const DEFAULT_DATA: EmailActionData = { + service: '', + host: '', + port: 0, + secure: false, + from: '', + user: '', + password: '', +}; + +const CREATE_LABEL = i18n.translate('xpack.monitoring.alerts.migrate.manageAction.createLabel', { + defaultMessage: 'Create email action', +}); +const SAVE_LABEL = i18n.translate('xpack.monitoring.alerts.migrate.manageAction.saveLabel', { + defaultMessage: 'Save email action', +}); +const CANCEL_LABEL = i18n.translate('xpack.monitoring.alerts.migrate.manageAction.cancelLabel', { + defaultMessage: 'Cancel', +}); + +const NEW_SERVICE_ID = '__new__'; + +export const ManageEmailAction: React.FC = ( + props: ManageActionModalProps +) => { + const { createEmailAction, cancel, isNew, action } = props; + + const defaultData = Object.assign({}, DEFAULT_DATA, action ? action.config : {}); + const [isSaving, setIsSaving] = React.useState(false); + const [showErrors, setShowErrors] = React.useState(false); + const [errors, setErrors] = React.useState( + getMissingFieldErrors(defaultData, DEFAULT_DATA) + ); + const [data, setData] = React.useState(defaultData); + const [createNewService, setCreateNewService] = React.useState(false); + const [newService, setNewService] = React.useState(''); + + React.useEffect(() => { + const missingFieldErrors = getMissingFieldErrors(data, DEFAULT_DATA); + if (!missingFieldErrors.service) { + if (data.service === NEW_SERVICE_ID && !newService) { + missingFieldErrors.service = getRequiredFieldError('service'); + } + } + setErrors(missingFieldErrors); + }, [data, newService]); + + async function saveEmailAction() { + setShowErrors(true); + if (!hasErrors(errors)) { + setShowErrors(false); + setIsSaving(true); + const mergedData = { + ...data, + service: data.service === NEW_SERVICE_ID ? newService : data.service, + }; + try { + await createEmailAction(mergedData); + } catch (err) { + setErrors({ + general: err.body.message, + }); + } + } + } + + const serviceOptions = ALERT_EMAIL_SERVICES.map(service => ({ + value: service, + inputDisplay: {service}, + dropdownDisplay: {service}, + })); + + serviceOptions.push({ + value: NEW_SERVICE_ID, + inputDisplay: ( + + {i18n.translate('xpack.monitoring.alerts.migrate.manageAction.addingNewServiceText', { + defaultMessage: 'Adding new service...', + })} + + ), + dropdownDisplay: ( + + {i18n.translate('xpack.monitoring.alerts.migrate.manageAction.addNewServiceText', { + defaultMessage: 'Add new service...', + })} + + ), + }); + + let addNewServiceUi = null; + if (createNewService) { + addNewServiceUi = ( + + + setNewService(e.target.value)} + isInvalid={showErrors} + /> + + ); + } + + return ( + + + {i18n.translate('xpack.monitoring.alerts.migrate.manageAction.serviceHelpText', { + defaultMessage: 'Find out more', + })} + + } + error={errors.service} + isInvalid={showErrors && !!errors.service} + > + + { + if (id === NEW_SERVICE_ID) { + setCreateNewService(true); + setData({ ...data, service: NEW_SERVICE_ID }); + } else { + setCreateNewService(false); + setData({ ...data, service: id }); + } + }} + hasDividers + isInvalid={showErrors && !!errors.service} + /> + {addNewServiceUi} + + + + + setData({ ...data, host: e.target.value })} + isInvalid={showErrors && !!errors.host} + /> + + + + setData({ ...data, port: parseInt(e.target.value, 10) })} + isInvalid={showErrors && !!errors.port} + /> + + + + setData({ ...data, secure: e.target.checked })} + /> + + + + setData({ ...data, from: e.target.value })} + isInvalid={showErrors && !!errors.from} + /> + + + + setData({ ...data, user: e.target.value })} + isInvalid={showErrors && !!errors.user} + /> + + + + setData({ ...data, password: e.target.value })} + isInvalid={showErrors && !!errors.password} + /> + + + + + + + + {isNew ? CREATE_LABEL : SAVE_LABEL} + + + {!action || isNew ? null : ( + + {CANCEL_LABEL} + + )} + + + ); +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx new file mode 100644 index 0000000000000..258a5b68db372 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { kfetch } from 'ui/kfetch'; +import { AlertsStatus, AlertsStatusProps } from './status'; +import { ALERT_TYPE_PREFIX } from '../../../common/constants'; +import { getSetupModeState } from '../../lib/setup_mode'; +import { mockUseEffects } from '../../jest.helpers'; + +jest.mock('../../lib/setup_mode', () => ({ + getSetupModeState: jest.fn(), + addSetupModeCallback: jest.fn(), + toggleSetupMode: jest.fn(), +})); + +jest.mock('ui/kfetch', () => ({ + kfetch: jest.fn(), +})); + +const defaultProps: AlertsStatusProps = { + clusterUuid: '1adsb23', + emailAddress: 'test@elastic.co', +}; + +describe('Status', () => { + beforeEach(() => { + mockUseEffects(2); + + (getSetupModeState as jest.Mock).mockReturnValue({ + enabled: false, + }); + + (kfetch as jest.Mock).mockImplementation(({ pathname }) => { + if (pathname === '/internal/security/api_key/privileges') { + return { areApiKeysEnabled: true }; + } + return { + data: [], + }; + }); + }); + + it('should render without setup mode', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + + it('should render a flyout when clicking the link', async () => { + (getSetupModeState as jest.Mock).mockReturnValue({ + enabled: true, + }); + + const component = shallow(); + component.find('EuiLink').simulate('click'); + await component.update(); + expect(component.find('EuiFlyout')).toMatchSnapshot(); + }); + + it('should render a success message if all alerts have been migrated and in setup mode', async () => { + (kfetch as jest.Mock).mockReturnValue({ + data: [ + { + alertTypeId: ALERT_TYPE_PREFIX, + }, + ], + }); + + (getSetupModeState as jest.Mock).mockReturnValue({ + enabled: true, + }); + + const component = shallow(); + await component.update(); + expect(component.find('EuiCallOut')).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx new file mode 100644 index 0000000000000..0ee0015ed39a7 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/alerts/status.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { kfetch } from 'ui/kfetch'; +import { + EuiSpacer, + EuiCallOut, + EuiTitle, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +import { Alert } from '../../../../alerting/server/types'; +import { getSetupModeState, addSetupModeCallback, toggleSetupMode } from '../../lib/setup_mode'; +import { NUMBER_OF_MIGRATED_ALERTS, ALERT_TYPE_PREFIX } from '../../../common/constants'; +import { AlertsConfiguration } from './configuration'; + +export interface AlertsStatusProps { + clusterUuid: string; + emailAddress: string; +} + +export const AlertsStatus: React.FC = (props: AlertsStatusProps) => { + const { emailAddress } = props; + + const [setupModeEnabled, setSetupModeEnabled] = React.useState(getSetupModeState().enabled); + const [kibanaAlerts, setKibanaAlerts] = React.useState([]); + const [showMigrationFlyout, setShowMigrationFlyout] = React.useState(false); + const [isSecurityConfigured, setIsSecurityConfigured] = React.useState(false); + + React.useEffect(() => { + async function fetchAlertsStatus() { + const alerts = await kfetch({ method: 'GET', pathname: `/api/alert/_find` }); + const monitoringAlerts = alerts.data.filter((alert: Alert) => + alert.alertTypeId.startsWith(ALERT_TYPE_PREFIX) + ); + setKibanaAlerts(monitoringAlerts); + } + + fetchAlertsStatus(); + fetchSecurityConfigured(); + }, [setupModeEnabled, showMigrationFlyout]); + + React.useEffect(() => { + if (!setupModeEnabled && showMigrationFlyout) { + setShowMigrationFlyout(false); + } + }, [setupModeEnabled, showMigrationFlyout]); + + async function fetchSecurityConfigured() { + const response = await kfetch({ pathname: '/internal/security/api_key/privileges' }); + setIsSecurityConfigured(response.areApiKeysEnabled); + } + + addSetupModeCallback(() => setSetupModeEnabled(getSetupModeState().enabled)); + + function enterSetupModeAndOpenFlyout() { + toggleSetupMode(true); + setShowMigrationFlyout(true); + } + + function getSecurityConfigurationErrorUi() { + if (isSecurityConfigured) { + return null; + } + + const link = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/security-settings.html#api-key-service-settings`; + return ( + + + +

+ + {i18n.translate( + 'xpack.monitoring.alerts.configuration.securityConfigurationError.docsLinkLabel', + { + defaultMessage: 'docs', + } + )} + + ), + }} + /> +

+
+
+ ); + } + + function renderContent() { + let flyout = null; + if (showMigrationFlyout) { + flyout = ( + setShowMigrationFlyout(false)} aria-labelledby="flyoutTitle"> + + +

+ {i18n.translate('xpack.monitoring.alerts.status.flyoutTitle', { + defaultMessage: 'Monitoring alerts', + })} +

+
+ +

+ {i18n.translate('xpack.monitoring.alerts.status.flyoutSubtitle', { + defaultMessage: 'Configure an email server and email address to receive alerts.', + })} +

+
+ {getSecurityConfigurationErrorUi()} +
+ + setShowMigrationFlyout(false)} + /> + +
+ ); + } + + const allMigrated = kibanaAlerts.length === NUMBER_OF_MIGRATED_ALERTS; + if (allMigrated) { + if (setupModeEnabled) { + return ( + + +

+ + {i18n.translate('xpack.monitoring.alerts.status.manage', { + defaultMessage: 'Want to make changes? Click here.', + })} + +

+
+ {flyout} +
+ ); + } + } else { + return ( + + +

+ + {i18n.translate('xpack.monitoring.alerts.status.needToMigrate', { + defaultMessage: 'Migrate cluster alerts to our new alerting platform.', + })} + +

+
+ {flyout} +
+ ); + } + } + + const content = renderContent(); + if (content) { + return ( + + {content} + + + ); + } + + return null; +}; diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js index 33b26c7ec56e0..a8001638f4399 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/alerts_panel.js @@ -4,11 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { Fragment } from 'react'; +import moment from 'moment-timezone'; import { FormattedAlert } from 'plugins/monitoring/components/alerts/formatted_alert'; import { mapSeverity } from 'plugins/monitoring/components/alerts/map_severity'; import { formatTimestampToDuration } from '../../../../common/format_timestamp_to_duration'; -import { CALCULATE_DURATION_SINCE } from '../../../../common/constants'; +import { + CALCULATE_DURATION_SINCE, + KIBANA_ALERTING_ENABLED, + ALERT_TYPE_LICENSE_EXPIRATION, + CALCULATE_DURATION_UNTIL, +} from '../../../../common/constants'; import { formatDateTimeLocal } from '../../../../common/formatting'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -21,6 +27,7 @@ import { EuiText, EuiSpacer, EuiCallOut, + EuiLink, } from '@elastic/eui'; export function AlertsPanel({ alerts, changeUrl }) { @@ -82,9 +89,52 @@ export function AlertsPanel({ alerts, changeUrl }) { ); } - const topAlertItems = alerts.map((item, index) => ( - - )); + const alertsList = KIBANA_ALERTING_ENABLED + ? alerts.map((alert, idx) => { + const callOutProps = mapSeverity(alert.severity); + let message = alert.message + // scan message prefix and replace relative times + // \w: Matches any alphanumeric character from the basic Latin alphabet, including the underscore. Equivalent to [A-Za-z0-9_]. + .replace( + '#relative', + formatTimestampToDuration(alert.expirationTime, CALCULATE_DURATION_UNTIL) + ) + .replace('#absolute', moment.tz(alert.expirationTime, moment.tz.guess()).format('LLL z')); + + if (!alert.isFiring) { + callOutProps.title = i18n.translate( + 'xpack.monitoring.cluster.overview.alertsPanel.severityIconTitle', + { + defaultMessage: '{severityIconTitle} (resolved {time} ago)', + values: { + severityIconTitle: callOutProps.title, + time: formatTimestampToDuration(alert.resolvedMS, CALCULATE_DURATION_SINCE), + }, + } + ); + callOutProps.color = 'success'; + callOutProps.iconType = 'check'; + } else { + if (alert.type === ALERT_TYPE_LICENSE_EXPIRATION) { + message = ( + + {message} +   + Please update your license + + ); + } + } + + return ( + +

{message}

+
+ ); + }) + : alerts.map((item, index) => ( + + )); return (
@@ -109,7 +159,7 @@ export function AlertsPanel({ alerts, changeUrl }) { - {topAlertItems} + {alertsList}
); diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js index cad4bbf411c34..eee51c416d11e 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js @@ -10,15 +10,22 @@ import { KibanaPanel } from './kibana_panel'; import { LogstashPanel } from './logstash_panel'; import { AlertsPanel } from './alerts_panel'; import { BeatsPanel } from './beats_panel'; - import { EuiPage, EuiPageBody, EuiScreenReaderOnly } from '@elastic/eui'; import { ApmPanel } from './apm_panel'; -import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants'; import { FormattedMessage } from '@kbn/i18n/react'; +import { AlertsStatus } from '../../alerts/status'; +import { + STANDALONE_CLUSTER_CLUSTER_UUID, + KIBANA_ALERTING_ENABLED, +} from '../../../../common/constants'; export function Overview(props) { const isFromStandaloneCluster = props.cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID; + const kibanaAlerts = KIBANA_ALERTING_ENABLED ? ( + + ) : null; + return ( @@ -30,6 +37,9 @@ export function Overview(props) { /> + + {kibanaAlerts} + {!isFromStandaloneCluster ? ( diff --git a/x-pack/legacy/plugins/monitoring/public/jest.helpers.ts b/x-pack/legacy/plugins/monitoring/public/jest.helpers.ts new file mode 100644 index 0000000000000..46ba603d30138 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/jest.helpers.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 React from 'react'; + +/** + * Suppress React 16.8 act() warnings globally. + * The react teams fix won't be out of alpha until 16.9.0. + * https://github.com/facebook/react/issues/14769#issuecomment-514589856 + */ +const consoleError = console.error; // eslint-disable-line no-console +beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation((...args) => { + if (!args[0].includes('Warning: An update to %s inside a test was not wrapped in act')) { + consoleError(...args); + } + }); +}); + +export function mockUseEffects(count = 1) { + const spy = jest.spyOn(React, 'useEffect'); + for (let i = 0; i < count; i++) { + spy.mockImplementationOnce(f => f()); + } +} + +// export function mockUseEffectForDeps(deps, count = 1) { +// const spy = jest.spyOn(React, 'useEffect'); +// for (let i = 0; i < count; i++) { +// spy.mockImplementationOnce((f, depList) => { + +// }); +// } +// } diff --git a/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.js b/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.tsx similarity index 94% rename from x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.js rename to x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.tsx index 9a51a88596926..22ce32103c208 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/ajax_error_handler.tsx @@ -7,12 +7,13 @@ import React from 'react'; import { contains } from 'lodash'; import { toastNotifications } from 'ui/notify'; +// @ts-ignore import { formatMsg } from 'ui/notify/lib'; import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; -export function formatMonitoringError(err) { +export function formatMonitoringError(err: any) { // TODO: We should stop using Boom for errors and instead write a custom handler to return richer error objects // then we can do better messages, such as highlighting the Cluster UUID instead of requiring it be part of the message if (err.status && err.status !== -1 && err.data) { @@ -33,10 +34,10 @@ export function formatMonitoringError(err) { return formatMsg(err); } -export function ajaxErrorHandlersProvider($injector) { +export function ajaxErrorHandlersProvider($injector: any) { const kbnUrl = $injector.get('kbnUrl'); - return err => { + return (err: any) => { if (err.status === 403) { // redirect to error message view kbnUrl.redirect('access-denied'); diff --git a/x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts b/x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts new file mode 100644 index 0000000000000..98d56f9790be4 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/lib/form_validation.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { isString, isNumber, capitalize } from 'lodash'; + +export function getRequiredFieldError(field: string): string { + return i18n.translate('xpack.monitoring.alerts.migrate.manageAction.requiredFieldError', { + defaultMessage: '{field} is a required field.', + values: { + field: capitalize(field), + }, + }); +} + +export function getMissingFieldErrors(data: any, defaultData: any) { + const errors: any = {}; + + for (const key in data) { + if (!data.hasOwnProperty(key)) { + continue; + } + + if (isString(defaultData[key])) { + if (!data[key] || data[key].length === 0) { + errors[key] = getRequiredFieldError(key); + } + } else if (isNumber(defaultData[key])) { + if (isNaN(data[key]) || data[key] === 0) { + errors[key] = getRequiredFieldError(key); + } + } + } + + return errors; +} + +export function hasErrors(errors: any) { + for (const error in errors) { + if (error.length) { + return true; + } + } + return false; +} diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js index aa931368b34c2..4a2b470f04c72 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js @@ -90,7 +90,7 @@ describe('setup_mode', () => { } catch (err) { error = err; } - expect(error).toEqual( + expect(error.message).toEqual( 'Unable to interact with setup ' + 'mode because the angular injector was not previously set. This needs to be ' + 'set by calling `initSetupModeState`.' @@ -255,9 +255,9 @@ describe('setup_mode', () => { await toggleSetupMode(true); injectorModulesMock.$http.post.mockClear(); await updateSetupModeData(undefined, true); - expect( - injectorModulesMock.$http.post - ).toHaveBeenCalledWith('../api/monitoring/v1/setup/collection/cluster', { ccs: undefined }); + const url = '../api/monitoring/v1/setup/collection/cluster'; + const args = { ccs: undefined }; + expect(injectorModulesMock.$http.post).toHaveBeenCalledWith(url, args); }); }); }); diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.tsx similarity index 76% rename from x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js rename to x-pack/legacy/plugins/monitoring/public/lib/setup_mode.tsx index 41aae01307617..d805c10247b2e 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.tsx @@ -6,31 +6,49 @@ import React from 'react'; import { render } from 'react-dom'; -import { ajaxErrorHandlersProvider } from './ajax_error_handler'; import { get, contains } from 'lodash'; import chrome from 'ui/chrome'; import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; -import { SetupModeEnterButton } from '../components/setup_mode/enter_button'; import { npSetup } from 'ui/new_platform'; +import { PluginsSetup } from 'ui/new_platform/new_platform'; +import { CloudSetup } from '../../../../../plugins/cloud/public'; +import { ajaxErrorHandlersProvider } from './ajax_error_handler'; +import { SetupModeEnterButton } from '../components/setup_mode/enter_button'; + +interface PluginsSetupWithCloud extends PluginsSetup { + cloud: CloudSetup; +} -function isOnPage(hash) { +function isOnPage(hash: string) { return contains(window.location.hash, hash); } -const angularState = { +interface IAngularState { + injector: any; + scope: any; +} + +const angularState: IAngularState = { injector: null, scope: null, }; const checkAngularState = () => { if (!angularState.injector || !angularState.scope) { - throw 'Unable to interact with setup mode because the angular injector was not previously set.' + - ' This needs to be set by calling `initSetupModeState`.'; + throw new Error( + 'Unable to interact with setup mode because the angular injector was not previously set.' + + ' This needs to be set by calling `initSetupModeState`.' + ); } }; -const setupModeState = { +interface ISetupModeState { + enabled: boolean; + data: any; + callbacks: Function[]; +} +const setupModeState: ISetupModeState = { enabled: false, data: null, callbacks: [], @@ -38,7 +56,7 @@ const setupModeState = { export const getSetupModeState = () => setupModeState; -export const setNewlyDiscoveredClusterUuid = clusterUuid => { +export const setNewlyDiscoveredClusterUuid = (clusterUuid: string) => { const globalState = angularState.injector.get('globalState'); const executor = angularState.injector.get('$executor'); angularState.scope.$apply(() => { @@ -48,7 +66,7 @@ export const setNewlyDiscoveredClusterUuid = clusterUuid => { executor.run(); }; -export const fetchCollectionData = async (uuid, fetchWithoutClusterUuid = false) => { +export const fetchCollectionData = async (uuid?: string, fetchWithoutClusterUuid = false) => { checkAngularState(); const http = angularState.injector.get('$http'); @@ -75,19 +93,19 @@ export const fetchCollectionData = async (uuid, fetchWithoutClusterUuid = false) } }; -const notifySetupModeDataChange = oldData => { - setupModeState.callbacks.forEach(cb => cb(oldData)); +const notifySetupModeDataChange = (oldData?: any) => { + setupModeState.callbacks.forEach((cb: Function) => cb(oldData)); }; -export const updateSetupModeData = async (uuid, fetchWithoutClusterUuid = false) => { +export const updateSetupModeData = async (uuid?: string, fetchWithoutClusterUuid = false) => { const oldData = setupModeState.data; const data = await fetchCollectionData(uuid, fetchWithoutClusterUuid); setupModeState.data = data; - const { cloud } = npSetup.plugins; + const { cloud } = npSetup.plugins as PluginsSetupWithCloud; const isCloudEnabled = !!(cloud && cloud.isCloudEnabled); const hasPermissions = get(data, '_meta.hasPermissions', false); if (isCloudEnabled || !hasPermissions) { - let text = null; + let text: string = ''; if (!hasPermissions) { text = i18n.translate('xpack.monitoring.setupMode.notAvailablePermissions', { defaultMessage: 'You do not have the necessary permissions to do this.', @@ -113,9 +131,9 @@ export const updateSetupModeData = async (uuid, fetchWithoutClusterUuid = false) const globalState = angularState.injector.get('globalState'); const clusterUuid = globalState.cluster_uuid; if (!clusterUuid) { - const liveClusterUuid = get(data, '_meta.liveClusterUuid'); + const liveClusterUuid: string = get(data, '_meta.liveClusterUuid'); const migratedEsNodes = Object.values(get(data, 'elasticsearch.byUuid', {})).filter( - node => node.isPartiallyMigrated || node.isFullyMigrated + (node: any) => node.isPartiallyMigrated || node.isFullyMigrated ); if (liveClusterUuid && migratedEsNodes.length > 0) { setNewlyDiscoveredClusterUuid(liveClusterUuid); @@ -140,7 +158,7 @@ export const disableElasticsearchInternalCollection = async () => { } }; -export const toggleSetupMode = inSetupMode => { +export const toggleSetupMode = (inSetupMode: boolean) => { checkAngularState(); const globalState = angularState.injector.get('globalState'); @@ -164,7 +182,7 @@ export const setSetupModeMenuItem = () => { } const globalState = angularState.injector.get('globalState'); - const { cloud } = npSetup.plugins; + const { cloud } = npSetup.plugins as PluginsSetupWithCloud; const isCloudEnabled = !!(cloud && cloud.isCloudEnabled); const enabled = !globalState.inSetupMode && !isCloudEnabled; @@ -174,10 +192,14 @@ export const setSetupModeMenuItem = () => { ); }; -export const initSetupModeState = async ($scope, $injector, callback) => { +export const addSetupModeCallback = (callback: Function) => setupModeState.callbacks.push(callback); + +export const initSetupModeState = async ($scope: any, $injector: any, callback?: Function) => { angularState.scope = $scope; angularState.injector = $injector; - callback && setupModeState.callbacks.push(callback); + if (callback) { + setupModeState.callbacks.push(callback); + } const globalState = $injector.get('globalState'); if (globalState.inSetupMode) { diff --git a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js index 57a7850b6fd53..1bfc76b766457 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/alerts/index.js @@ -24,7 +24,7 @@ function getPageData($injector) { const globalState = $injector.get('globalState'); const $http = $injector.get('$http'); const Private = $injector.get('Private'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/alerts`; + const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/legacy_alerts`; const timeBounds = timefilter.getBounds(); diff --git a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js index bec90f3230571..e7107860d61fa 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/views/cluster/overview/index.js @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Fragment } from 'react'; +import { isEmpty } from 'lodash'; +import chrome from 'ui/chrome'; import { i18n } from '@kbn/i18n'; import uiRoutes from 'ui/routes'; import { routeInitProvider } from 'plugins/monitoring/lib/route_init'; @@ -12,7 +14,11 @@ import { MonitoringViewBaseController } from '../../'; import { Overview } from 'plugins/monitoring/components/cluster/overview'; import { I18nContext } from 'ui/i18n'; import { SetupModeRenderer } from '../../../components/renderers'; -import { CODE_PATH_ALL } from '../../../../common/constants'; +import { + CODE_PATH_ALL, + MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS, + KIBANA_ALERTING_ENABLED, +} from '../../../../common/constants'; const CODE_PATHS = [CODE_PATH_ALL]; @@ -31,6 +37,7 @@ uiRoutes.when('/overview', { const monitoringClusters = $injector.get('monitoringClusters'); const globalState = $injector.get('globalState'); const showLicenseExpiration = $injector.get('showLicenseExpiration'); + const config = $injector.get('config'); super({ title: i18n.translate('xpack.monitoring.cluster.overviewTitle', { @@ -58,7 +65,16 @@ uiRoutes.when('/overview', { $scope.$watch( () => this.data, - data => { + async data => { + if (isEmpty(data)) { + return; + } + + let emailAddress = chrome.getInjected('monitoringLegacyEmailAddress') || ''; + if (KIBANA_ALERTING_ENABLED) { + emailAddress = config.get(MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS) || emailAddress; + } + this.renderReact( new Promise(resolve => resolve()), + alertInstanceFactory: (id: string) => new AlertInstance(), + savedObjectsClient: {} as jest.Mocked, + }, + params: {}, + state: {}, + spaceId: '', + name: '', + tags: [], + createdBy: null, + updatedBy: null, +}; + +describe('getLicenseExpiration', () => { + const emailAddress = 'foo@foo.com'; + const server: any = { + newPlatform: { + __internals: { + uiSettings: { + asScopedToClient: (): any => ({ + get: () => new Promise(resolve => resolve(emailAddress)), + }), + }, + }, + }, + }; + const getMonitoringCluster: () => void = jest.fn(); + const logger: Logger = { + warn: jest.fn(), + log: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + info: jest.fn(), + get: jest.fn(), + }; + const getLogger = (): Logger => logger; + const ccrEnabled = false; + + afterEach(() => { + (logger.warn as jest.Mock).mockClear(); + }); + + it('should have the right id and actionGroups', () => { + const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled); + expect(alert.id).toBe(ALERT_TYPE_LICENSE_EXPIRATION); + expect(alert.actionGroups).toEqual(['default']); + }); + + it('should return the state if no license is provided', async () => { + const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled); + + const services: MockServices | AlertServices = { + callCluster: jest.fn(), + alertInstanceFactory: jest.fn(), + savedObjectsClient: savedObjectsClientMock.create(), + }; + const state = { foo: 1 }; + + const result = await alert.executor({ + ...alertExecutorOptions, + services, + params, + state, + }); + + expect(result).toEqual(state); + }); + + it('should log a warning if no email is provided', async () => { + const customServer: any = { + newPlatform: { + __internals: { + uiSettings: { + asScopedToClient: () => ({ + get: () => null, + }), + }, + }, + }, + }; + const alert = getLicenseExpiration(customServer, getMonitoringCluster, getLogger, ccrEnabled); + + const services = { + callCluster: jest.fn( + (method: string, { filterPath }): Promise => { + return new Promise(resolve => { + if (filterPath.includes('hits.hits._source.license.*')) { + resolve( + fillLicense({ + status: 'good', + type: 'basic', + expiry_date_in_millis: moment() + .add(7, 'days') + .valueOf(), + }) + ); + } + resolve({}); + }); + } + ), + alertInstanceFactory: jest.fn(), + savedObjectsClient: savedObjectsClientMock.create(), + }; + + const state = {}; + + await alert.executor({ + ...alertExecutorOptions, + services, + params, + state, + }); + + expect((logger.warn as jest.Mock).mock.calls.length).toBe(1); + expect(logger.warn).toHaveBeenCalledWith( + `Unable to send email for ${ALERT_TYPE_LICENSE_EXPIRATION} because there is no email configured.` + ); + }); + + it('should fire actions if going to expire', async () => { + const scheduleActions = jest.fn(); + const alertInstanceFactory = jest.fn( + (id: string): AlertInstance => { + const instance = new AlertInstance(); + instance.scheduleActions = scheduleActions; + return instance; + } + ); + + const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled); + + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockReturnValue( + new Promise(resolve => { + const savedObject: SavedObject = { + id: '', + type: '', + references: [], + attributes: { + [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress, + }, + }; + resolve(savedObject); + }) + ); + const services = { + callCluster: jest.fn( + (method: string, { filterPath }): Promise => { + return new Promise(resolve => { + if (filterPath.includes('hits.hits._source.license.*')) { + resolve( + fillLicense( + { + status: 'active', + type: 'gold', + expiry_date_in_millis: moment() + .add(7, 'days') + .valueOf(), + }, + clusterUuid + ) + ); + } + resolve({}); + }); + } + ), + alertInstanceFactory, + savedObjectsClient, + }; + + const state = {}; + + const result: AlertState = (await alert.executor({ + ...alertExecutorOptions, + services, + params, + state, + })) as AlertState; + + const newState: AlertClusterState = result[clusterUuid] as AlertClusterState; + + expect(newState.expiredCheckDateMS > 0).toBe(true); + expect(scheduleActions.mock.calls.length).toBe(1); + expect(scheduleActions.mock.calls[0][1].subject).toBe( + 'NEW X-Pack Monitoring: License Expiration' + ); + expect(scheduleActions.mock.calls[0][1].to).toBe(emailAddress); + }); + + it('should fire actions if the user fixed their license', async () => { + const scheduleActions = jest.fn(); + const alertInstanceFactory = jest.fn( + (id: string): AlertInstance => { + const instance = new AlertInstance(); + instance.scheduleActions = scheduleActions; + return instance; + } + ); + const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled); + + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockReturnValue( + new Promise(resolve => { + const savedObject: SavedObject = { + id: '', + type: '', + references: [], + attributes: { + [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress, + }, + }; + resolve(savedObject); + }) + ); + const services = { + callCluster: jest.fn( + (method: string, { filterPath }): Promise => { + return new Promise(resolve => { + if (filterPath.includes('hits.hits._source.license.*')) { + resolve( + fillLicense( + { + status: 'active', + type: 'gold', + expiry_date_in_millis: moment() + .add(120, 'days') + .valueOf(), + }, + clusterUuid + ) + ); + } + resolve({}); + }); + } + ), + alertInstanceFactory, + savedObjectsClient, + }; + + const state: AlertState = { + [clusterUuid]: { + expiredCheckDateMS: moment() + .subtract(1, 'day') + .valueOf(), + ui: { isFiring: true, severity: 0, message: null, resolvedMS: 0, expirationTime: 0 }, + }, + }; + + const result: AlertState = (await alert.executor({ + ...alertExecutorOptions, + services, + params, + state, + })) as AlertState; + + const newState: AlertClusterState = result[clusterUuid] as AlertClusterState; + expect(newState.expiredCheckDateMS).toBe(0); + expect(scheduleActions.mock.calls.length).toBe(1); + expect(scheduleActions.mock.calls[0][1].subject).toBe( + 'RESOLVED X-Pack Monitoring: License Expiration' + ); + expect(scheduleActions.mock.calls[0][1].to).toBe(emailAddress); + }); + + it('should not fire actions for trial license that expire in more than 14 days', async () => { + const scheduleActions = jest.fn(); + const alertInstanceFactory = jest.fn( + (id: string): AlertInstance => { + const instance = new AlertInstance(); + instance.scheduleActions = scheduleActions; + return instance; + } + ); + const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled); + + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockReturnValue( + new Promise(resolve => { + const savedObject: SavedObject = { + id: '', + type: '', + references: [], + attributes: { + [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress, + }, + }; + resolve(savedObject); + }) + ); + const services = { + callCluster: jest.fn( + (method: string, { filterPath }): Promise => { + return new Promise(resolve => { + if (filterPath.includes('hits.hits._source.license.*')) { + resolve( + fillLicense( + { + status: 'active', + type: 'trial', + expiry_date_in_millis: moment() + .add(15, 'days') + .valueOf(), + }, + clusterUuid + ) + ); + } + resolve({}); + }); + } + ), + alertInstanceFactory, + savedObjectsClient, + }; + + const state = {}; + const result: AlertState = (await alert.executor({ + ...alertExecutorOptions, + services, + params, + state, + })) as AlertState; + + const newState: AlertClusterState = result[clusterUuid] as AlertClusterState; + expect(newState.expiredCheckDateMS).toBe(undefined); + expect(scheduleActions).not.toHaveBeenCalled(); + }); + + it('should fire actions for trial license that in 14 days or less', async () => { + const scheduleActions = jest.fn(); + const alertInstanceFactory = jest.fn( + (id: string): AlertInstance => { + const instance = new AlertInstance(); + instance.scheduleActions = scheduleActions; + return instance; + } + ); + const alert = getLicenseExpiration(server, getMonitoringCluster, getLogger, ccrEnabled); + + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockReturnValue( + new Promise(resolve => { + const savedObject: SavedObject = { + id: '', + type: '', + references: [], + attributes: { + [MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS]: emailAddress, + }, + }; + resolve(savedObject); + }) + ); + const services = { + callCluster: jest.fn( + (method: string, { filterPath }): Promise => { + return new Promise(resolve => { + if (filterPath.includes('hits.hits._source.license.*')) { + resolve( + fillLicense( + { + status: 'active', + type: 'trial', + expiry_date_in_millis: moment() + .add(13, 'days') + .valueOf(), + }, + clusterUuid + ) + ); + } + resolve({}); + }); + } + ), + alertInstanceFactory, + savedObjectsClient, + }; + + const state = {}; + const result: AlertState = (await alert.executor({ + ...alertExecutorOptions, + services, + params, + state, + })) as AlertState; + + const newState: AlertClusterState = result[clusterUuid] as AlertClusterState; + expect(newState.expiredCheckDateMS > 0).toBe(true); + expect(scheduleActions.mock.calls.length).toBe(1); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts b/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts new file mode 100644 index 0000000000000..197c5c9cdcbc7 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/alerts/license_expiration.ts @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment-timezone'; +import { get } from 'lodash'; +import { Legacy } from 'kibana'; +import { Logger } from 'src/core/server'; +import { ALERT_TYPE_LICENSE_EXPIRATION, INDEX_PATTERN_ELASTICSEARCH } from '../../common/constants'; +import { AlertType } from '../../../alerting'; +import { fetchLicenses } from '../lib/alerts/fetch_licenses'; +import { fetchDefaultEmailAddress } from '../lib/alerts/fetch_default_email_address'; +import { fetchClusters } from '../lib/alerts/fetch_clusters'; +import { fetchAvailableCcs } from '../lib/alerts/fetch_available_ccs'; +import { + AlertLicense, + AlertState, + AlertClusterState, + AlertClusterUiState, + LicenseExpirationAlertExecutorOptions, +} from './types'; +import { getCcsIndexPattern } from '../lib/alerts/get_ccs_index_pattern'; +import { executeActions, getUiMessage } from '../lib/alerts/license_expiration.lib'; + +const EXPIRES_DAYS = [60, 30, 14, 7]; + +export const getLicenseExpiration = ( + server: Legacy.Server, + getMonitoringCluster: any, + getLogger: (contexts: string[]) => Logger, + ccsEnabled: boolean +): AlertType => { + async function getCallCluster(services: any): Promise { + const monitoringCluster = await getMonitoringCluster(); + if (!monitoringCluster) { + return services.callCluster; + } + + return monitoringCluster.callCluster; + } + + const logger = getLogger([ALERT_TYPE_LICENSE_EXPIRATION]); + return { + id: ALERT_TYPE_LICENSE_EXPIRATION, + name: 'Monitoring Alert - License Expiration', + actionGroups: ['default'], + async executor({ + services, + params, + state, + }: LicenseExpirationAlertExecutorOptions): Promise { + logger.debug( + `Firing alert with params: ${JSON.stringify(params)} and state: ${JSON.stringify(state)}` + ); + + const callCluster = await getCallCluster(services); + + // Support CCS use cases by querying to find available remote clusters + // and then adding those to the index pattern we are searching against + let esIndexPattern = INDEX_PATTERN_ELASTICSEARCH; + if (ccsEnabled) { + const availableCcs = await fetchAvailableCcs(callCluster); + if (availableCcs.length > 0) { + esIndexPattern = getCcsIndexPattern(esIndexPattern, availableCcs); + } + } + + const clusters = await fetchClusters(callCluster, esIndexPattern); + + // Fetch licensing information from cluster_stats documents + const licenses: AlertLicense[] = await fetchLicenses(callCluster, clusters, esIndexPattern); + if (licenses.length === 0) { + logger.warn(`No license found for ${ALERT_TYPE_LICENSE_EXPIRATION}.`); + return state; + } + + const uiSettings = server.newPlatform.__internals.uiSettings.asScopedToClient( + services.savedObjectsClient + ); + const dateFormat: string = await uiSettings.get('dateFormat'); + const timezone: string = await uiSettings.get('dateFormat:tz'); + const emailAddress = await fetchDefaultEmailAddress(uiSettings); + if (!emailAddress) { + // TODO: we can do more here + logger.warn( + `Unable to send email for ${ALERT_TYPE_LICENSE_EXPIRATION} because there is no email configured.` + ); + return; + } + + const result: AlertState = { ...state }; + + for (const license of licenses) { + const licenseState: AlertClusterState = state[license.clusterUuid] || {}; + const $expiry = moment.utc(license.expiryDateMS); + let isExpired = false; + let severity = 0; + + if (license.status !== 'active') { + isExpired = true; + severity = 2001; + } else if (license.expiryDateMS) { + for (let i = EXPIRES_DAYS.length - 1; i >= 0; i--) { + if (license.type === 'trial' && i < 2) { + break; + } + + const $fromNow = moment.utc().add(EXPIRES_DAYS[i], 'days'); + if ($fromNow.isAfter($expiry)) { + isExpired = true; + severity = 1000 * i; + break; + } + } + } + + const ui: AlertClusterUiState = get(licenseState, 'ui', { + isFiring: false, + message: null, + severity: 0, + resolvedMS: 0, + expirationTime: 0, + }); + let resolved = ui.resolvedMS; + let message = ui.message; + let expiredCheckDate = licenseState.expiredCheckDateMS; + const instance = services.alertInstanceFactory(ALERT_TYPE_LICENSE_EXPIRATION); + + if (isExpired) { + if (!licenseState.expiredCheckDateMS) { + logger.debug(`License will expire soon, sending email`); + executeActions(instance, license, $expiry, dateFormat, emailAddress); + expiredCheckDate = moment().valueOf(); + } + message = getUiMessage(license, timezone); + resolved = 0; + } else if (!isExpired && licenseState.expiredCheckDateMS) { + logger.debug(`License expiration has been resolved, sending email`); + executeActions(instance, license, $expiry, dateFormat, emailAddress, true); + expiredCheckDate = 0; + message = getUiMessage(license, timezone, true); + resolved = moment().valueOf(); + } + + result[license.clusterUuid] = { + expiredCheckDateMS: expiredCheckDate, + ui: { + message, + expirationTime: license.expiryDateMS, + isFiring: expiredCheckDate > 0, + severity, + resolvedMS: resolved, + }, + }; + } + + return result; + }, + }; +}; diff --git a/x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts b/x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts new file mode 100644 index 0000000000000..6346ca00dabbd --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/alerts/types.d.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Moment } from 'moment'; +import { AlertExecutorOptions } from '../../../alerting'; + +export interface AlertLicense { + status: string; + type: string; + expiryDateMS: number; + clusterUuid: string; + clusterName: string; +} + +export interface AlertState { + [clusterUuid: string]: AlertClusterState; +} + +export interface AlertClusterState { + expiredCheckDateMS: number | Moment; + ui: AlertClusterUiState; +} + +export interface AlertClusterUiState { + isFiring: boolean; + severity: number; + message: string | null; + resolvedMS: number; + expirationTime: number; +} + +export interface AlertCluster { + clusterUuid: string; +} + +export interface LicenseExpirationAlertExecutorOptions extends AlertExecutorOptions { + state: AlertState; +} + +export interface AlertParams { + dateFormat: string; + timezone: string; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts new file mode 100644 index 0000000000000..4398b2dd675ec --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { fetchAvailableCcs } from './fetch_available_ccs'; + +describe('fetchAvailableCcs', () => { + it('should call the `cluster.remoteInfo` api', async () => { + const callCluster = jest.fn(); + await fetchAvailableCcs(callCluster); + expect(callCluster).toHaveBeenCalledWith('cluster.remoteInfo'); + }); + + it('should return clusters that are connected', async () => { + const connectedRemote = 'myRemote'; + const callCluster = jest.fn().mockImplementation(() => ({ + [connectedRemote]: { + connected: true, + }, + })); + const result = await fetchAvailableCcs(callCluster); + expect(result).toEqual([connectedRemote]); + }); + + it('should not return clusters that are connected', async () => { + const disconnectedRemote = 'myRemote'; + const callCluster = jest.fn().mockImplementation(() => ({ + [disconnectedRemote]: { + connected: false, + }, + })); + const result = await fetchAvailableCcs(callCluster); + expect(result.length).toBe(0); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.ts new file mode 100644 index 0000000000000..34efaff93f34c --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_available_ccs.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. + */ +export async function fetchAvailableCcs(callCluster: any): Promise { + const availableCcs = []; + const response = await callCluster('cluster.remoteInfo'); + for (const remoteName in response) { + if (!response.hasOwnProperty(remoteName)) { + continue; + } + const remoteInfo = response[remoteName]; + if (remoteInfo.connected) { + availableCcs.push(remoteName); + } + } + return availableCcs; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts new file mode 100644 index 0000000000000..78eb9773df15f --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.test.ts @@ -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 { fetchClusters } from './fetch_clusters'; + +describe('fetchClusters', () => { + it('return a list of clusters', async () => { + const callCluster = jest.fn().mockImplementation(() => ({ + aggregations: { + clusters: { + buckets: [ + { + key: 'clusterA', + }, + ], + }, + }, + })); + const index = '.monitoring-es-*'; + const result = await fetchClusters(callCluster, index); + expect(result).toEqual([{ clusterUuid: 'clusterA' }]); + }); + + it('should limit the time period in the query', async () => { + const callCluster = jest.fn(); + const index = '.monitoring-es-*'; + await fetchClusters(callCluster, index); + const params = callCluster.mock.calls[0][1]; + expect(params.body.query.bool.filter[1].range.timestamp.gte).toBe('now-2m'); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.ts new file mode 100644 index 0000000000000..8ef7339618a2c --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_clusters.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { get } from 'lodash'; +import { AlertCluster } from '../../alerts/types'; + +interface AggregationResult { + key: string; +} + +export async function fetchClusters(callCluster: any, index: string): Promise { + const params = { + index, + filterPath: 'aggregations.clusters.buckets', + body: { + size: 0, + query: { + bool: { + filter: [ + { + term: { + type: 'cluster_stats', + }, + }, + { + range: { + timestamp: { + gte: 'now-2m', + }, + }, + }, + ], + }, + }, + aggs: { + clusters: { + terms: { + field: 'cluster_uuid', + size: 1000, + }, + }, + }, + }, + }; + + const response = await callCluster('search', params); + return get(response, 'aggregations.clusters.buckets', []).map((bucket: AggregationResult) => ({ + clusterUuid: bucket.key, + })); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.test.ts new file mode 100644 index 0000000000000..25b09b956038a --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.test.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 { fetchDefaultEmailAddress } from './fetch_default_email_address'; +import { uiSettingsServiceMock } from '../../../../../../../src/core/server/mocks'; + +describe('fetchDefaultEmailAddress', () => { + it('get the email address', async () => { + const email = 'test@test.com'; + const uiSettingsClient = uiSettingsServiceMock.createClient(); + uiSettingsClient.get.mockResolvedValue(email); + const result = await fetchDefaultEmailAddress(uiSettingsClient); + expect(result).toBe(email); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.ts new file mode 100644 index 0000000000000..88e4199a88256 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_default_email_address.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 { IUiSettingsClient } from 'src/core/server'; +import { MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS } from '../../../common/constants'; + +export async function fetchDefaultEmailAddress( + uiSettingsClient: IUiSettingsClient +): Promise { + return await uiSettingsClient.get(MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts new file mode 100644 index 0000000000000..dd6c074e68b1f --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { fetchLicenses } from './fetch_licenses'; + +describe('fetchLicenses', () => { + it('return a list of licenses', async () => { + const clusterName = 'MyCluster'; + const clusterUuid = 'clusterA'; + const license = { + status: 'active', + expiry_date_in_millis: 1579532493876, + type: 'basic', + }; + const callCluster = jest.fn().mockImplementation(() => ({ + hits: { + hits: [ + { + _source: { + license, + cluster_name: clusterName, + cluster_uuid: clusterUuid, + }, + }, + ], + }, + })); + const clusters = [{ clusterUuid }]; + const index = '.monitoring-es-*'; + const result = await fetchLicenses(callCluster, clusters, index); + expect(result).toEqual([ + { + status: license.status, + type: license.type, + expiryDateMS: license.expiry_date_in_millis, + clusterUuid, + clusterName, + }, + ]); + }); + + it('should only search for the clusters provided', async () => { + const clusterUuid = 'clusterA'; + const callCluster = jest.fn(); + const clusters = [{ clusterUuid }]; + const index = '.monitoring-es-*'; + await fetchLicenses(callCluster, clusters, index); + const params = callCluster.mock.calls[0][1]; + expect(params.body.query.bool.filter[0].terms.cluster_uuid).toEqual([clusterUuid]); + }); + + it('should limit the time period in the query', async () => { + const clusterUuid = 'clusterA'; + const callCluster = jest.fn(); + const clusters = [{ clusterUuid }]; + const index = '.monitoring-es-*'; + await fetchLicenses(callCluster, clusters, index); + const params = callCluster.mock.calls[0][1]; + expect(params.body.query.bool.filter[2].range.timestamp.gte).toBe('now-2m'); + }); + + it('should give priority to the metadata name', async () => { + const clusterName = 'MyCluster'; + const clusterUuid = 'clusterA'; + const license = { + status: 'active', + expiry_date_in_millis: 1579532493876, + type: 'basic', + }; + const callCluster = jest.fn().mockImplementation(() => ({ + hits: { + hits: [ + { + _source: { + license, + cluster_name: 'fakeName', + cluster_uuid: clusterUuid, + cluster_settings: { + cluster: { + metadata: { + display_name: clusterName, + }, + }, + }, + }, + }, + ], + }, + })); + const clusters = [{ clusterUuid }]; + const index = '.monitoring-es-*'; + const result = await fetchLicenses(callCluster, clusters, index); + expect(result).toEqual([ + { + status: license.status, + type: license.type, + expiryDateMS: license.expiry_date_in_millis, + clusterUuid, + clusterName, + }, + ]); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.ts new file mode 100644 index 0000000000000..31a68e8aa9c3e --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_licenses.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 { get } from 'lodash'; +import { AlertLicense, AlertCluster } from '../../alerts/types'; + +export async function fetchLicenses( + callCluster: any, + clusters: AlertCluster[], + index: string +): Promise { + const params = { + index, + filterPath: [ + 'hits.hits._source.license.*', + 'hits.hits._source.cluster_settings.cluster.metadata.display_name', + 'hits.hits._source.cluster_uuid', + 'hits.hits._source.cluster_name', + ], + body: { + size: 1, + sort: [{ timestamp: { order: 'desc' } }], + query: { + bool: { + filter: [ + { + terms: { + cluster_uuid: clusters.map(cluster => cluster.clusterUuid), + }, + }, + { + term: { + type: 'cluster_stats', + }, + }, + { + range: { + timestamp: { + gte: 'now-2m', + }, + }, + }, + ], + }, + }, + }, + }; + + const response = await callCluster('search', params); + return get(response, 'hits.hits', []).map((hit: any) => { + const clusterName: string = + get(hit, '_source.cluster_settings.cluster.metadata.display_name') || + get(hit, '_source.cluster_name') || + get(hit, '_source.cluster_uuid'); + const rawLicense: any = get(hit, '_source.license', {}); + const license: AlertLicense = { + status: rawLicense.status, + type: rawLicense.type, + expiryDateMS: rawLicense.expiry_date_in_millis, + clusterUuid: get(hit, '_source.cluster_uuid'), + clusterName, + }; + return license; + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_status.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_status.ts new file mode 100644 index 0000000000000..9f7c1d5a994d2 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/fetch_status.ts @@ -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 moment from 'moment'; +import { get } from 'lodash'; +import { AlertClusterState } from '../../alerts/types'; +import { ALERT_TYPES, LOGGING_TAG } from '../../../common/constants'; + +export async function fetchStatus( + callCluster: any, + start: number, + end: number, + clusterUuid: string, + server: any +): Promise { + // TODO: this shouldn't query task manager directly but rather + // use an api exposed by the alerting/actions plugin + // See https://github.com/elastic/kibana/issues/48442 + const statuses = await Promise.all( + ALERT_TYPES.map( + type => + new Promise(async (resolve, reject) => { + try { + const params = { + index: '.kibana_task_manager', + filterPath: ['hits.hits._source.task.state'], + body: { + size: 1, + sort: [{ updated_at: { order: 'desc' } }], + query: { + bool: { + filter: [ + { + term: { + 'task.taskType': `alerting:${type}`, + }, + }, + ], + }, + }, + }, + }; + + const response = await callCluster('search', params); + const state = get(response, 'hits.hits[0]._source.task.state', '{}'); + const clusterState: AlertClusterState = get( + JSON.parse(state), + `alertTypeState.${clusterUuid}`, + { + expiredCheckDateMS: 0, + ui: { + isFiring: false, + message: null, + severity: 0, + resolvedMS: 0, + expirationTime: 0, + }, + } + ); + const isInBetween = moment(clusterState.ui.resolvedMS).isBetween(start, end); + if (clusterState.ui.isFiring || isInBetween) { + return resolve({ + type, + ...clusterState.ui, + }); + } + return resolve(false); + } catch (err) { + const reason = get(err, 'body.error.type'); + if (reason === 'index_not_found_exception') { + server.log( + ['error', LOGGING_TAG], + `Unable to fetch alerts. Alerts depends on task manager, which has not been started yet.` + ); + } else { + server.log(['error', LOGGING_TAG], err.message); + } + return resolve(false); + } + }) + ) + ); + + return statuses.filter(Boolean); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.test.ts new file mode 100644 index 0000000000000..a5eb104986161 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getCcsIndexPattern } from './get_ccs_index_pattern'; + +describe('getCcsIndexPattern', () => { + it('should return an index pattern including remotes', () => { + const remotes = ['Remote1', 'Remote2']; + const index = '.monitoring-es-*'; + const result = getCcsIndexPattern(index, remotes); + expect(result).toBe('.monitoring-es-*,Remote1:.monitoring-es-*,Remote2:.monitoring-es-*'); + }); + + it('should return an index pattern from multiple index patterns including remotes', () => { + const remotes = ['Remote1', 'Remote2']; + const index = '.monitoring-es-*,.monitoring-kibana-*'; + const result = getCcsIndexPattern(index, remotes); + expect(result).toBe( + '.monitoring-es-*,.monitoring-kibana-*,Remote1:.monitoring-es-*,Remote2:.monitoring-es-*,Remote1:.monitoring-kibana-*,Remote2:.monitoring-kibana-*' + ); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts new file mode 100644 index 0000000000000..b562fde2a0810 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/get_ccs_index_pattern.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export function getCcsIndexPattern(indexPattern: string, remotes: string[]): string { + return `${indexPattern},${indexPattern + .split(',') + .map(pattern => { + return remotes.map(remoteName => `${remoteName}:${pattern}`).join(','); + }) + .join(',')}`; +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts new file mode 100644 index 0000000000000..1a2eb1e44be84 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import moment from 'moment-timezone'; +import { executeActions, getUiMessage } from './license_expiration.lib'; + +describe('licenseExpiration lib', () => { + describe('executeActions', () => { + const clusterName = 'clusterA'; + const instance: any = { scheduleActions: jest.fn() }; + const license: any = { clusterName }; + const $expiry = moment('2020-01-20'); + const dateFormat = 'dddd, MMMM Do YYYY, h:mm:ss a'; + const emailAddress = 'test@test.com'; + + beforeEach(() => { + instance.scheduleActions.mockClear(); + }); + + it('should schedule actions when firing', () => { + executeActions(instance, license, $expiry, dateFormat, emailAddress, false); + expect(instance.scheduleActions).toHaveBeenCalledWith('default', { + subject: 'NEW X-Pack Monitoring: License Expiration', + message: `Cluster '${clusterName}' license is going to expire on Monday, January 20th 2020, 12:00:00 am. Please update your license.`, + to: emailAddress, + }); + }); + + it('should schedule actions when resolved', () => { + executeActions(instance, license, $expiry, dateFormat, emailAddress, true); + expect(instance.scheduleActions).toHaveBeenCalledWith('default', { + subject: 'RESOLVED X-Pack Monitoring: License Expiration', + message: `This cluster alert has been resolved: Cluster '${clusterName}' license was going to expire on Monday, January 20th 2020, 12:00:00 am.`, + to: emailAddress, + }); + }); + }); + + describe('getUiMessage', () => { + const timezone = 'Europe/London'; + const license: any = { expiryDateMS: moment.tz('2020-01-20 08:00:00', timezone).utc() }; + + it('should return a message when firing', () => { + const message = getUiMessage(license, timezone, false); + expect(message).toBe(`This cluster's license is going to expire in #relative at #absolute.`); + }); + + it('should return a message when resolved', () => { + const message = getUiMessage(license, timezone, true); + expect(message).toBe(`This cluster's license is active.`); + }); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts b/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.ts new file mode 100644 index 0000000000000..8a75fc1fbbd82 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/alerts/license_expiration.lib.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. + */ +import { Moment } from 'moment-timezone'; +import { i18n } from '@kbn/i18n'; +import { AlertInstance } from '../../../../alerting/server/alert_instance'; +import { AlertLicense } from '../../alerts/types'; + +const RESOLVED_SUBJECT = i18n.translate( + 'xpack.monitoring.alerts.licenseExpiration.resolvedSubject', + { + defaultMessage: 'RESOLVED X-Pack Monitoring: License Expiration', + } +); + +const NEW_SUBJECT = i18n.translate('xpack.monitoring.alerts.licenseExpiration.newSubject', { + defaultMessage: 'NEW X-Pack Monitoring: License Expiration', +}); + +export function executeActions( + instance: AlertInstance, + license: AlertLicense, + $expiry: Moment, + dateFormat: string, + emailAddress: string, + resolved: boolean = false +) { + if (resolved) { + instance.scheduleActions('default', { + subject: RESOLVED_SUBJECT, + message: `This cluster alert has been resolved: Cluster '${ + license.clusterName + }' license was going to expire on ${$expiry.format(dateFormat)}.`, + to: emailAddress, + }); + } else { + instance.scheduleActions('default', { + subject: NEW_SUBJECT, + message: `Cluster '${license.clusterName}' license is going to expire on ${$expiry.format( + dateFormat + )}. Please update your license.`, + to: emailAddress, + }); + } +} + +export function getUiMessage(license: AlertLicense, timezone: string, resolved: boolean = false) { + if (resolved) { + return i18n.translate('xpack.monitoring.alerts.licenseExpiration.ui.resolvedMessage', { + defaultMessage: `This cluster's license is active.`, + }); + } + return i18n.translate('xpack.monitoring.alerts.licenseExpiration.ui.firingMessage', { + defaultMessage: `This cluster's license is going to expire in #relative at #absolute.`, + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js index 2b080a5c333fc..a5426dc04545e 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/cluster/get_clusters_from_request.js @@ -16,6 +16,7 @@ import { getBeatsForClusters } from '../beats'; import { alertsClustersAggregation } from '../../cluster_alerts/alerts_clusters_aggregation'; import { alertsClusterSearch } from '../../cluster_alerts/alerts_cluster_search'; import { checkLicense as checkLicenseForAlerts } from '../../cluster_alerts/check_license'; +import { fetchStatus } from '../alerts/fetch_status'; import { getClustersSummary } from './get_clusters_summary'; import { CLUSTER_ALERTS_SEARCH_SIZE, @@ -27,6 +28,7 @@ import { CODE_PATH_LOGSTASH, CODE_PATH_BEATS, CODE_PATH_APM, + KIBANA_ALERTING_ENABLED, } from '../../../common/constants'; import { getApmsForClusters } from '../apm/get_apms_for_clusters'; import { i18n } from '@kbn/i18n'; @@ -99,15 +101,31 @@ export async function getClustersFromRequest( if (mlJobs !== null) { cluster.ml = { jobs: mlJobs }; } - const alerts = isInCodePath(codePaths, [CODE_PATH_ALERTS]) - ? await alertsClusterSearch(req, alertsIndex, cluster, checkLicenseForAlerts, { + + if (isInCodePath(codePaths, [CODE_PATH_ALERTS])) { + if (KIBANA_ALERTING_ENABLED) { + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + const callCluster = (...args) => callWithRequest(req, ...args); + cluster.alerts = await fetchStatus( + callCluster, start, end, - size: CLUSTER_ALERTS_SEARCH_SIZE, - }) - : null; - if (alerts) { - cluster.alerts = alerts; + cluster.cluster_uuid, + req.server + ); + } else { + cluster.alerts = await alertsClusterSearch( + req, + alertsIndex, + cluster, + checkLicenseForAlerts, + { + start, + end, + size: CLUSTER_ALERTS_SEARCH_SIZE, + } + ); + } } cluster.logs = isInCodePath(codePaths, [CODE_PATH_LOGS]) diff --git a/x-pack/legacy/plugins/monitoring/server/lib/get_date_format.js b/x-pack/legacy/plugins/monitoring/server/lib/get_date_format.js new file mode 100644 index 0000000000000..89cbf20d9b56f --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/lib/get_date_format.js @@ -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. + */ + +export async function getDateFormat(req) { + return await req.getUiSettingsService().get('dateFormat'); +} diff --git a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js index 5f52e0c6a983b..a12b48510a6ff 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js @@ -348,7 +348,6 @@ export const getCollectionStatus = async ( }, }; } - const liveClusterUuid = skipLiveData ? null : await getLiveElasticsearchClusterUuid(req); const isLiveCluster = !clusterUuid || liveClusterUuid === clusterUuid; diff --git a/x-pack/legacy/plugins/monitoring/server/plugin.js b/x-pack/legacy/plugins/monitoring/server/plugin.js index ef346e95ad075..50e5319a0f526 100644 --- a/x-pack/legacy/plugins/monitoring/server/plugin.js +++ b/x-pack/legacy/plugins/monitoring/server/plugin.js @@ -5,12 +5,17 @@ */ import { i18n } from '@kbn/i18n'; -import { LOGGING_TAG, KIBANA_MONITORING_LOGGING_TAG } from '../common/constants'; +import { + LOGGING_TAG, + KIBANA_MONITORING_LOGGING_TAG, + KIBANA_ALERTING_ENABLED, +} from '../common/constants'; import { requireUIRoutes } from './routes'; import { instantiateClient } from './es_client/instantiate_client'; import { initMonitoringXpackInfo } from './init_monitoring_xpack_info'; import { initBulkUploader, registerCollectors } from './kibana_monitoring'; import { registerMonitoringCollection } from './telemetry_collection'; +import { getLicenseExpiration } from './alerts/license_expiration'; import { parseElasticsearchConfig } from './es_client/parse_elasticsearch_config'; export class Plugin { @@ -133,5 +138,37 @@ export class Plugin { showCgroupMetricsLogstash: config.get('monitoring.ui.container.logstash.enabled'), // Note, not currently used, but see https://github.com/elastic/x-pack-kibana/issues/1559 part 2 }; }); + + if (KIBANA_ALERTING_ENABLED && plugins.alerting) { + // this is not ready right away but we need to register alerts right away + async function getMonitoringCluster() { + const configs = config.get('xpack.monitoring.elasticsearch'); + if (configs.hosts) { + const monitoringCluster = plugins.elasticsearch.getCluster('monitoring'); + const { username, password } = configs; + const fakeRequest = { + headers: { + authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + }, + }; + return { + callCluster: (...args) => monitoringCluster.callWithRequest(fakeRequest, ...args), + }; + } + return null; + } + + function getLogger(contexts) { + return core.logger.get('plugins', LOGGING_TAG, ...contexts); + } + plugins.alerting.setup.registerType( + getLicenseExpiration( + core._hapi, + getMonitoringCluster, + getLogger, + config.get('xpack.monitoring.ccs.enabled') + ) + ); + } } } diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js new file mode 100644 index 0000000000000..f87683effe437 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/alerts.js @@ -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 Joi from 'joi'; +import { isFunction } from 'lodash'; +import { + ALERT_TYPE_LICENSE_EXPIRATION, + MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS, +} from '../../../../../common/constants'; + +async function createAlerts(req, alertsClient, { selectedEmailActionId }) { + const createdAlerts = []; + + // Create alerts + const ALERT_TYPES = { + [ALERT_TYPE_LICENSE_EXPIRATION]: { + schedule: { interval: '10s' }, + actions: [ + { + group: 'default', + id: selectedEmailActionId, + params: { + subject: '{{context.subject}}', + message: `{{context.message}}`, + to: ['{{context.to}}'], + }, + }, + ], + }, + }; + + for (const alertTypeId of Object.keys(ALERT_TYPES)) { + const existingAlert = await alertsClient.find({ + options: { + search: alertTypeId, + }, + }); + if (existingAlert.total === 1) { + await alertsClient.delete({ id: existingAlert.data[0].id }); + } + + const result = await alertsClient.create({ + data: { + enabled: true, + alertTypeId, + ...ALERT_TYPES[alertTypeId], + }, + }); + createdAlerts.push(result); + } + + return createdAlerts; +} + +async function saveEmailAddress(emailAddress, uiSettingsService) { + await uiSettingsService.set(MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS, emailAddress); +} + +export function createKibanaAlertsRoute(server) { + server.route({ + method: 'POST', + path: '/api/monitoring/v1/alerts', + config: { + validate: { + payload: Joi.object({ + selectedEmailActionId: Joi.string().required(), + emailAddress: Joi.string().required(), + }), + }, + }, + async handler(req, headers) { + const { emailAddress, selectedEmailActionId } = req.payload; + const alertsClient = isFunction(req.getAlertsClient) ? req.getAlertsClient() : null; + if (!alertsClient) { + return headers.response().code(404); + } + + const [alerts, emailResponse] = await Promise.all([ + createAlerts(req, alertsClient, { ...req.params, selectedEmailActionId }), + saveEmailAddress(emailAddress, req.getUiSettingsService()), + ]); + + return { alerts, emailResponse }; + }, + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js index cdcd776b349fc..246cdfde97cff 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/index.js @@ -4,54 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; -import { alertsClusterSearch } from '../../../../cluster_alerts/alerts_cluster_search'; -import { checkLicense } from '../../../../cluster_alerts/check_license'; -import { getClusterLicense } from '../../../../lib/cluster/get_cluster_license'; -import { prefixIndexPattern } from '../../../../lib/ccs_utils'; -import { INDEX_PATTERN_ELASTICSEARCH, INDEX_ALERTS } from '../../../../../common/constants'; - -/* - * Cluster Alerts route. - */ -export function clusterAlertsRoute(server) { - server.route({ - method: 'POST', - path: '/api/monitoring/v1/clusters/{clusterUuid}/alerts', - config: { - validate: { - params: Joi.object({ - clusterUuid: Joi.string().required(), - }), - payload: Joi.object({ - ccs: Joi.string().optional(), - timeRange: Joi.object({ - min: Joi.date().required(), - max: Joi.date().required(), - }).required(), - }), - }, - }, - handler(req) { - const config = server.config(); - const ccs = req.payload.ccs; - const clusterUuid = req.params.clusterUuid; - const esIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_ELASTICSEARCH, ccs); - const alertsIndex = prefixIndexPattern(config, INDEX_ALERTS, ccs); - const options = { - start: req.payload.timeRange.min, - end: req.payload.timeRange.max, - }; - - return getClusterLicense(req, esIndexPattern, clusterUuid).then(license => - alertsClusterSearch( - req, - alertsIndex, - { cluster_uuid: clusterUuid, license }, - checkLicense, - options - ) - ); - }, - }); -} +export * from './legacy_alerts'; +export * from './alerts'; diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js new file mode 100644 index 0000000000000..a3049f0f3e2d2 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/alerts/legacy_alerts.js @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import { alertsClusterSearch } from '../../../../cluster_alerts/alerts_cluster_search'; +import { checkLicense } from '../../../../cluster_alerts/check_license'; +import { getClusterLicense } from '../../../../lib/cluster/get_cluster_license'; +import { prefixIndexPattern } from '../../../../lib/ccs_utils'; +import { INDEX_PATTERN_ELASTICSEARCH, INDEX_ALERTS } from '../../../../../common/constants'; + +/* + * Cluster Alerts route. + */ +export function legacyClusterAlertsRoute(server) { + server.route({ + method: 'POST', + path: '/api/monitoring/v1/clusters/{clusterUuid}/legacy_alerts', + config: { + validate: { + params: Joi.object({ + clusterUuid: Joi.string().required(), + }), + payload: Joi.object({ + ccs: Joi.string().optional(), + timeRange: Joi.object({ + min: Joi.date().required(), + max: Joi.date().required(), + }).required(), + }), + }, + }, + handler(req) { + const config = server.config(); + const ccs = req.payload.ccs; + const clusterUuid = req.params.clusterUuid; + const esIndexPattern = prefixIndexPattern(config, INDEX_PATTERN_ELASTICSEARCH, ccs); + const alertsIndex = prefixIndexPattern(config, INDEX_ALERTS, ccs); + const options = { + start: req.payload.timeRange.min, + end: req.payload.timeRange.max, + }; + + return getClusterLicense(req, esIndexPattern, clusterUuid).then(license => + alertsClusterSearch( + req, + alertsIndex, + { cluster_uuid: clusterUuid, license }, + checkLicense, + options + ) + ); + }, + }); +} diff --git a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js index baffbfd5f3f6f..de0213ec84689 100644 --- a/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js +++ b/x-pack/legacy/plugins/monitoring/server/routes/api/v1/ui.js @@ -6,7 +6,7 @@ // all routes for the app export { checkAccessRoute } from './check_access'; -export { clusterAlertsRoute } from './alerts/'; +export * from './alerts/'; export { beatsDetailRoute, beatsListingRoute, beatsOverviewRoute } from './beats'; export { clusterRoute, clustersRoute } from './cluster'; export { diff --git a/x-pack/legacy/plugins/monitoring/ui_exports.js b/x-pack/legacy/plugins/monitoring/ui_exports.js index 9251deb673bd1..49f167b0f1b10 100644 --- a/x-pack/legacy/plugins/monitoring/ui_exports.js +++ b/x-pack/legacy/plugins/monitoring/ui_exports.js @@ -6,6 +6,10 @@ import { i18n } from '@kbn/i18n'; import { resolve } from 'path'; +import { + MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS, + KIBANA_ALERTING_ENABLED, +} from './common/constants'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; /** @@ -14,28 +18,48 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; * app (injectDefaultVars and hacks) * @return {Object} data per Kibana plugin uiExport schema */ -export const getUiExports = () => ({ - app: { - title: i18n.translate('xpack.monitoring.stackMonitoringTitle', { - defaultMessage: 'Stack Monitoring', - }), - order: 9002, - description: i18n.translate('xpack.monitoring.uiExportsDescription', { - defaultMessage: 'Monitoring for Elastic Stack', - }), - icon: 'plugins/monitoring/icons/monitoring.svg', - euiIconType: 'monitoringApp', - linkToLastSubUrl: false, - main: 'plugins/monitoring/monitoring', - category: DEFAULT_APP_CATEGORIES.management, - }, - injectDefaultVars(server) { - const config = server.config(); - return { - monitoringUiEnabled: config.get('monitoring.ui.enabled'), +export const getUiExports = () => { + const uiSettingDefaults = {}; + if (KIBANA_ALERTING_ENABLED) { + uiSettingDefaults[MONITORING_CONFIG_ALERTING_EMAIL_ADDRESS] = { + name: i18n.translate('xpack.monitoring.alertingEmailAddress.name', { + defaultMessage: 'Alerting email address', + }), + value: '', + description: i18n.translate('xpack.monitoring.alertingEmailAddress.description', { + defaultMessage: `The default email address to receive alerts from Stack Monitoring`, + }), + category: ['monitoring'], }; - }, - hacks: ['plugins/monitoring/hacks/toggle_app_link_in_nav'], - home: ['plugins/monitoring/register_feature'], - styleSheetPaths: resolve(__dirname, 'public/index.scss'), -}); + } + + return { + app: { + title: i18n.translate('xpack.monitoring.stackMonitoringTitle', { + defaultMessage: 'Stack Monitoring', + }), + order: 9002, + description: i18n.translate('xpack.monitoring.uiExportsDescription', { + defaultMessage: 'Monitoring for Elastic Stack', + }), + icon: 'plugins/monitoring/icons/monitoring.svg', + euiIconType: 'monitoringApp', + linkToLastSubUrl: false, + main: 'plugins/monitoring/monitoring', + category: DEFAULT_APP_CATEGORIES.management, + }, + injectDefaultVars(server) { + const config = server.config(); + return { + monitoringUiEnabled: config.get('monitoring.ui.enabled'), + monitoringLegacyEmailAddress: config.get( + 'monitoring.cluster_alerts.email_notifications.email_address' + ), + }; + }, + uiSettingDefaults, + hacks: ['plugins/monitoring/hacks/toggle_app_link_in_nav'], + home: ['plugins/monitoring/register_feature'], + styleSheetPaths: resolve(__dirname, 'public/index.scss'), + }; +}; diff --git a/x-pack/plugins/actions/common/types.ts b/x-pack/plugins/actions/common/types.ts index 784125b83859d..fbd7404a2f15e 100644 --- a/x-pack/plugins/actions/common/types.ts +++ b/x-pack/plugins/actions/common/types.ts @@ -9,3 +9,10 @@ export interface ActionType { name: string; enabled: boolean; } + +export interface ActionResult { + id: string; + actionTypeId: string; + name: string; + config: Record; +} From d5ba32ff085cbbe4f9e0478ba261a6a2d2af129c Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 3 Feb 2020 13:11:48 -0800 Subject: [PATCH 05/10] [APM] Fixes maxTraceItems in waterfall trace and related error queries (#56111) * Addresses #55494 by combining waterfall trace and related error queries. Makes sure that maxTraceItems is only respected for Transactions and Spans. * addressed feedback back separating the combined query into 1 for transactions/spans and 1 for errors + errorCount Co-authored-by: Elastic Machine --- .../Marks/__test__/get_error_marks.test.ts | 15 +- .../Marks/get_error_marks.ts | 9 +- .../WaterfallContainer/Waterfall/index.tsx | 6 +- .../waterfall_helpers.test.ts.snap | 141 +++++++++--------- .../waterfall_helpers.test.ts | 17 ++- .../waterfall_helpers/waterfall_helpers.ts | 91 +++++++++-- .../plugins/apm/public/hooks/useWaterfall.ts | 2 +- .../errors/__snapshots__/queries.test.ts.snap | 64 -------- .../get_trace_errors_per_transaction.ts | 65 -------- .../apm/server/lib/errors/queries.test.ts | 9 -- .../traces/__snapshots__/queries.test.ts.snap | 50 +++---- .../apm/server/lib/traces/get_trace.ts | 9 +- .../apm/server/lib/traces/get_trace_items.ts | 80 +++++++++- x-pack/legacy/plugins/apm/typings/common.d.ts | 4 + 14 files changed, 267 insertions(+), 295 deletions(-) delete mode 100644 x-pack/legacy/plugins/apm/server/lib/errors/get_trace_errors_per_transaction.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_error_marks.test.ts b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_error_marks.test.ts index 8fd8edd7f8a72..b7e83073a205b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_error_marks.test.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/__test__/get_error_marks.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IWaterfallItem } from '../../Waterfall/waterfall_helpers/waterfall_helpers'; +import { IWaterfallError } from '../../Waterfall/waterfall_helpers/waterfall_helpers'; import { getErrorMarks } from '../get_error_marks'; describe('getErrorMarks', () => { @@ -12,13 +12,6 @@ describe('getErrorMarks', () => { it('when items are missing', () => { expect(getErrorMarks([], {})).toEqual([]); }); - it('when any error is available', () => { - const items = [ - { docType: 'span' }, - { docType: 'transaction' } - ] as IWaterfallItem[]; - expect(getErrorMarks(items, {})).toEqual([]); - }); }); it('returns error marks', () => { @@ -29,14 +22,13 @@ describe('getErrorMarks', () => { skew: 5, doc: { error: { id: 1 }, service: { name: 'opbeans-java' } } } as unknown, - { docType: 'transaction' }, { docType: 'error', offset: 50, skew: 0, doc: { error: { id: 2 }, service: { name: 'opbeans-node' } } } as unknown - ] as IWaterfallItem[]; + ] as IWaterfallError[]; expect( getErrorMarks(items, { 'opbeans-java': 'red', 'opbeans-node': 'blue' }) ).toEqual([ @@ -67,14 +59,13 @@ describe('getErrorMarks', () => { skew: 5, doc: { error: { id: 1 }, service: { name: 'opbeans-java' } } } as unknown, - { docType: 'transaction' }, { docType: 'error', offset: 50, skew: 0, doc: { error: { id: 2 }, service: { name: 'opbeans-node' } } } as unknown - ] as IWaterfallItem[]; + ] as IWaterfallError[]; expect(getErrorMarks(items, {})).toEqual([ { type: 'errorMark', diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts index f1f0163a49d10..e2b00c13c5c1f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Marks/get_error_marks.ts @@ -6,7 +6,6 @@ import { isEmpty } from 'lodash'; import { ErrorRaw } from '../../../../../../../typings/es_schemas/raw/ErrorRaw'; import { - IWaterfallItem, IWaterfallError, IServiceColors } from '../Waterfall/waterfall_helpers/waterfall_helpers'; @@ -19,16 +18,14 @@ export interface ErrorMark extends Mark { } export const getErrorMarks = ( - items: IWaterfallItem[], + errorItems: IWaterfallError[], serviceColors: IServiceColors ): ErrorMark[] => { - if (isEmpty(items)) { + if (isEmpty(errorItems)) { return []; } - return (items.filter( - item => item.docType === 'error' - ) as IWaterfallError[]).map(error => ({ + return errorItems.map(error => ({ type: 'errorMark', offset: error.offset + error.skew, verticalLine: false, diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx index b48fc1cf7ca27..4f584786f2f9a 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx @@ -80,13 +80,9 @@ export const Waterfall: React.FC = ({ const { serviceColors, duration } = waterfall; const agentMarks = getAgentMarks(waterfall.entryTransaction); - const errorMarks = getErrorMarks(waterfall.items, serviceColors); + const errorMarks = getErrorMarks(waterfall.errorItems, serviceColors); const renderWaterfallItem = (item: IWaterfallItem) => { - if (item.docType === 'error') { - return null; - } - const errorCount = item.docType === 'transaction' ? waterfall.errorsPerTransaction[item.doc.transaction.id] diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap index 5b1b9be33c375..c9b29e8692f44 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/__snapshots__/waterfall_helpers.test.ts.snap @@ -24,6 +24,77 @@ Object { "name": "GET /api", }, }, + "errorItems": Array [ + Object { + "doc": Object { + "agent": Object { + "name": "ruby", + "version": "2", + }, + "error": Object { + "grouping_key": "errorGroupingKey1", + "id": "error1", + "log": Object { + "message": "error message", + }, + }, + "parent": Object { + "id": "myTransactionId1", + }, + "processor": Object { + "event": "error", + }, + "service": Object { + "name": "opbeans-ruby", + }, + "timestamp": Object { + "us": 1549324795810000, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "id": "myTransactionId1", + }, + }, + "docType": "error", + "duration": 0, + "id": "error1", + "offset": 25994, + "parent": Object { + "doc": Object { + "processor": Object { + "event": "transaction", + }, + "service": Object { + "name": "opbeans-node", + }, + "timestamp": Object { + "us": 1549324795784006, + }, + "trace": Object { + "id": "myTraceId", + }, + "transaction": Object { + "duration": Object { + "us": 49660, + }, + "id": "myTransactionId1", + "name": "GET /api", + }, + }, + "docType": "transaction", + "duration": 49660, + "id": "myTransactionId1", + "offset": 0, + "parent": undefined, + "parentId": undefined, + "skew": 0, + }, + "parentId": "myTransactionId1", + "skew": 0, + }, + ], "errorsCount": 1, "errorsPerTransaction": Object { "myTransactionId1": 2, @@ -716,75 +787,6 @@ Object { "parentId": "mySpanIdA", "skew": 0, }, - Object { - "doc": Object { - "agent": Object { - "name": "ruby", - "version": "2", - }, - "error": Object { - "grouping_key": "errorGroupingKey1", - "id": "error1", - "log": Object { - "message": "error message", - }, - }, - "parent": Object { - "id": "myTransactionId1", - }, - "processor": Object { - "event": "error", - }, - "service": Object { - "name": "opbeans-ruby", - }, - "timestamp": Object { - "us": 1549324795810000, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "id": "myTransactionId1", - }, - }, - "docType": "error", - "duration": 0, - "id": "error1", - "offset": 25994, - "parent": Object { - "doc": Object { - "processor": Object { - "event": "transaction", - }, - "service": Object { - "name": "opbeans-node", - }, - "timestamp": Object { - "us": 1549324795784006, - }, - "trace": Object { - "id": "myTraceId", - }, - "transaction": Object { - "duration": Object { - "us": 49660, - }, - "id": "myTransactionId1", - "name": "GET /api", - }, - }, - "docType": "transaction", - "duration": 49660, - "id": "myTransactionId1", - "offset": 0, - "parent": undefined, - "parentId": undefined, - "skew": 0, - }, - "parentId": "myTransactionId1", - "skew": 0, - }, ], "rootTransaction": Object { "processor": Object { @@ -848,6 +850,7 @@ Object { "name": "Api::ProductsController#index", }, }, + "errorItems": Array [], "errorsCount": 0, "errorsPerTransaction": Object { "myTransactionId1": 2, diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts index 426842bc02f51..6b13b93200c61 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.test.ts @@ -12,7 +12,8 @@ import { getOrderedWaterfallItems, getWaterfall, IWaterfallItem, - IWaterfallTransaction + IWaterfallTransaction, + IWaterfallError } from './waterfall_helpers'; import { APMError } from '../../../../../../../../typings/es_schemas/ui/APMError'; @@ -100,7 +101,9 @@ describe('waterfall_helpers', () => { } }, timestamp: { us: 1549324795823304 } - } as unknown) as Transaction, + } as unknown) as Transaction + ]; + const errorDocs = [ ({ processor: { event: 'error' }, parent: { id: 'myTransactionId1' }, @@ -130,14 +133,15 @@ describe('waterfall_helpers', () => { }; const waterfall = getWaterfall( { - trace: { items: hits, exceedsMax: false }, + trace: { items: hits, errorDocs, exceedsMax: false }, errorsPerTransaction }, entryTransactionId ); - expect(waterfall.items.length).toBe(7); + expect(waterfall.items.length).toBe(6); expect(waterfall.items[0].id).toBe('myTransactionId1'); + expect(waterfall.errorItems.length).toBe(1); expect(waterfall.errorsCount).toEqual(1); expect(waterfall).toMatchSnapshot(); }); @@ -150,7 +154,7 @@ describe('waterfall_helpers', () => { }; const waterfall = getWaterfall( { - trace: { items: hits, exceedsMax: false }, + trace: { items: hits, errorDocs, exceedsMax: false }, errorsPerTransaction }, entryTransactionId @@ -158,6 +162,7 @@ describe('waterfall_helpers', () => { expect(waterfall.items.length).toBe(4); expect(waterfall.items[0].id).toBe('myTransactionId2'); + expect(waterfall.errorItems.length).toBe(0); expect(waterfall.errorsCount).toEqual(0); expect(waterfall).toMatchSnapshot(); }); @@ -386,7 +391,7 @@ describe('waterfall_helpers', () => { it('should return parent skew for errors', () => { const child = { docType: 'error' - } as IWaterfallItem; + } as IWaterfallError; const parent = { docType: 'transaction', diff --git a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts index 1af6cddb3ba4a..3b52163aa7fa4 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/waterfall_helpers/waterfall_helpers.ts @@ -24,6 +24,8 @@ interface IWaterfallGroup { [key: string]: IWaterfallItem[]; } +const ROOT_ID = 'root'; + export interface IWaterfall { entryTransaction?: Transaction; rootTransaction?: Transaction; @@ -36,6 +38,7 @@ export interface IWaterfall { errorsPerTransaction: TraceAPIResponse['errorsPerTransaction']; errorsCount: number; serviceColors: IServiceColors; + errorItems: IWaterfallError[]; } interface IWaterfallItemBase { @@ -70,10 +73,7 @@ export type IWaterfallTransaction = IWaterfallItemBase< export type IWaterfallSpan = IWaterfallItemBase; export type IWaterfallError = IWaterfallItemBase; -export type IWaterfallItem = - | IWaterfallTransaction - | IWaterfallSpan - | IWaterfallError; +export type IWaterfallItem = IWaterfallTransaction | IWaterfallSpan; function getTransactionItem(transaction: Transaction): IWaterfallTransaction { return { @@ -99,20 +99,34 @@ function getSpanItem(span: Span): IWaterfallSpan { }; } -function getErrorItem(error: APMError): IWaterfallError { - return { +function getErrorItem( + error: APMError, + items: IWaterfallItem[], + entryWaterfallTransaction?: IWaterfallTransaction +): IWaterfallError { + const entryTimestamp = entryWaterfallTransaction?.doc.timestamp.us ?? 0; + const parent = items.find( + waterfallItem => waterfallItem.id === error.parent?.id + ); + const errorItem: IWaterfallError = { docType: 'error', doc: error, id: error.error.id, - parentId: error.parent?.id, - offset: 0, + parent, + parentId: parent?.id, + offset: error.timestamp.us - entryTimestamp, skew: 0, duration: 0 }; + + return { + ...errorItem, + skew: getClockSkew(errorItem, parent) + }; } export function getClockSkew( - item: IWaterfallItem, + item: IWaterfallItem | IWaterfallError, parentItem?: IWaterfallItem ) { if (!parentItem) { @@ -218,13 +232,11 @@ const getWaterfallItems = (items: TraceAPIResponse['trace']['items']) => return getSpanItem(item as Span); case 'transaction': return getTransactionItem(item as Transaction); - case 'error': - return getErrorItem(item as APMError); } }); const getChildrenGroupedByParentId = (waterfallItems: IWaterfallItem[]) => - groupBy(waterfallItems, item => (item.parentId ? item.parentId : 'root')); + groupBy(waterfallItems, item => (item.parentId ? item.parentId : ROOT_ID)); const getEntryWaterfallTransaction = ( entryTransactionId: string, @@ -234,6 +246,48 @@ const getEntryWaterfallTransaction = ( item => item.docType === 'transaction' && item.id === entryTransactionId ) as IWaterfallTransaction; +function isInEntryTransaction( + parentIdLookup: Map, + entryTransactionId: string, + currentId: string +): boolean { + if (currentId === entryTransactionId) { + return true; + } + const parentId = parentIdLookup.get(currentId); + if (parentId) { + return isInEntryTransaction(parentIdLookup, entryTransactionId, parentId); + } + return false; +} + +function getWaterfallErrors( + errorDocs: TraceAPIResponse['trace']['errorDocs'], + items: IWaterfallItem[], + entryWaterfallTransaction?: IWaterfallTransaction +) { + const errorItems = errorDocs.map(errorDoc => + getErrorItem(errorDoc, items, entryWaterfallTransaction) + ); + if (!entryWaterfallTransaction) { + return errorItems; + } + const parentIdLookup = [...items, ...errorItems].reduce( + (map, { id, parentId }) => { + map.set(id, parentId ?? ROOT_ID); + return map; + }, + new Map() + ); + return errorItems.filter(errorItem => + isInEntryTransaction( + parentIdLookup, + entryWaterfallTransaction?.id, + errorItem.id + ) + ); +} + export function getWaterfall( { trace, errorsPerTransaction }: TraceAPIResponse, entryTransactionId?: Transaction['transaction']['id'] @@ -244,7 +298,8 @@ export function getWaterfall( items: [], errorsPerTransaction, errorsCount: sum(Object.values(errorsPerTransaction)), - serviceColors: {} + serviceColors: {}, + errorItems: [] }; } @@ -261,6 +316,11 @@ export function getWaterfall( childrenByParentId, entryWaterfallTransaction ); + const errorItems = getWaterfallErrors( + trace.errorDocs, + items, + entryWaterfallTransaction + ); const rootTransaction = getRootTransaction(childrenByParentId); const duration = getWaterfallDuration(items); @@ -274,7 +334,8 @@ export function getWaterfall( duration, items, errorsPerTransaction, - errorsCount: items.filter(item => item.docType === 'error').length, - serviceColors + errorsCount: errorItems.length, + serviceColors, + errorItems }; } diff --git a/x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts b/x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts index f3f10c0e46d9b..8d623a0268222 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useWaterfall.ts @@ -11,7 +11,7 @@ import { getWaterfall } from '../components/app/TransactionDetails/WaterfallWith const INITIAL_DATA = { root: undefined, - trace: { items: [], exceedsMax: false }, + trace: { items: [], exceedsMax: false, errorDocs: [] }, errorsPerTransaction: {} }; diff --git a/x-pack/legacy/plugins/apm/server/lib/errors/__snapshots__/queries.test.ts.snap b/x-pack/legacy/plugins/apm/server/lib/errors/__snapshots__/queries.test.ts.snap index 88d8edd17454a..a2629366dd6d9 100644 --- a/x-pack/legacy/plugins/apm/server/lib/errors/__snapshots__/queries.test.ts.snap +++ b/x-pack/legacy/plugins/apm/server/lib/errors/__snapshots__/queries.test.ts.snap @@ -205,67 +205,3 @@ Object { "index": "myIndex", } `; - -exports[`error queries fetches trace errors 1`] = ` -Object { - "body": Object { - "aggs": Object { - "transactions": Object { - "terms": Object { - "execution_hint": "map", - "field": "transaction.id", - }, - }, - }, - "query": Object { - "bool": Object { - "filter": Array [ - Object { - "term": Object { - "trace.id": "foo", - }, - }, - Object { - "term": Object { - "processor.event": "error", - }, - }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, - }, - }, - ], - "should": Array [ - Object { - "bool": Object { - "must_not": Array [ - Object { - "exists": Object { - "field": "error.log.level", - }, - }, - ], - }, - }, - Object { - "terms": Object { - "error.log.level": Array [ - "critical", - "error", - "fatal", - ], - }, - }, - ], - }, - }, - "size": 0, - }, - "index": "myIndex", -} -`; diff --git a/x-pack/legacy/plugins/apm/server/lib/errors/get_trace_errors_per_transaction.ts b/x-pack/legacy/plugins/apm/server/lib/errors/get_trace_errors_per_transaction.ts deleted file mode 100644 index 6027693be5180..0000000000000 --- a/x-pack/legacy/plugins/apm/server/lib/errors/get_trace_errors_per_transaction.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - ERROR_LOG_LEVEL, - PROCESSOR_EVENT, - TRACE_ID, - TRANSACTION_ID -} from '../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../helpers/range_filter'; -import { Setup, SetupTimeRange } from '../helpers/setup_request'; - -export interface ErrorsPerTransaction { - [transactionId: string]: number; -} - -const includedLogLevels = ['critical', 'error', 'fatal']; - -export async function getTraceErrorsPerTransaction( - traceId: string, - setup: Setup & SetupTimeRange -): Promise { - const { start, end, client, indices } = setup; - - const params = { - index: indices['apm_oss.errorIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [TRACE_ID]: traceId } }, - { term: { [PROCESSOR_EVENT]: 'error' } }, - { range: rangeFilter(start, end) } - ], - should: [ - { bool: { must_not: [{ exists: { field: ERROR_LOG_LEVEL } }] } }, - { terms: { [ERROR_LOG_LEVEL]: includedLogLevels } } - ] - } - }, - aggs: { - transactions: { - terms: { - field: TRANSACTION_ID, - // high cardinality - execution_hint: 'map' - } - } - } - } - } as const; - - const resp = await client.search(params); - return (resp.aggregations?.transactions.buckets || []).reduce( - (acc, bucket) => ({ - ...acc, - [bucket.key]: bucket.doc_count - }), - {} - ); -} diff --git a/x-pack/legacy/plugins/apm/server/lib/errors/queries.test.ts b/x-pack/legacy/plugins/apm/server/lib/errors/queries.test.ts index 2b1704d9424e4..f1e5d31efd4bd 100644 --- a/x-pack/legacy/plugins/apm/server/lib/errors/queries.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/errors/queries.test.ts @@ -6,7 +6,6 @@ import { getErrorGroup } from './get_error_group'; import { getErrorGroups } from './get_error_groups'; -import { getTraceErrorsPerTransaction } from './get_trace_errors_per_transaction'; import { SearchParamsMock, inspectSearchParams @@ -56,12 +55,4 @@ describe('error queries', () => { expect(mock.params).toMatchSnapshot(); }); - - it('fetches trace errors', async () => { - mock = await inspectSearchParams(setup => - getTraceErrorsPerTransaction('foo', setup) - ); - - expect(mock.params).toMatchSnapshot(); - }); }); diff --git a/x-pack/legacy/plugins/apm/server/lib/traces/__snapshots__/queries.test.ts.snap b/x-pack/legacy/plugins/apm/server/lib/traces/__snapshots__/queries.test.ts.snap index a2828e1d74920..0a9f9d38b2be7 100644 --- a/x-pack/legacy/plugins/apm/server/lib/traces/__snapshots__/queries.test.ts.snap +++ b/x-pack/legacy/plugins/apm/server/lib/traces/__snapshots__/queries.test.ts.snap @@ -3,6 +3,15 @@ exports[`trace queries fetches a trace 1`] = ` Object { "body": Object { + "aggs": Object { + "by_transaction_id": Object { + "terms": Object { + "execution_hint": "map", + "field": "transaction.id", + "size": "myIndex", + }, + }, + }, "query": Object { "bool": Object { "filter": Array [ @@ -12,12 +21,8 @@ Object { }, }, Object { - "terms": Object { - "processor.event": Array [ - "span", - "transaction", - "error", - ], + "term": Object { + "processor.event": "error", }, }, Object { @@ -30,36 +35,19 @@ Object { }, }, ], - "should": Object { - "exists": Object { - "field": "parent.id", + "must_not": Object { + "terms": Object { + "error.log.level": Array [ + "debug", + "info", + "warning", + ], }, }, }, }, "size": "myIndex", - "sort": Array [ - Object { - "_score": Object { - "order": "asc", - }, - }, - Object { - "transaction.duration.us": Object { - "order": "desc", - }, - }, - Object { - "span.duration.us": Object { - "order": "desc", - }, - }, - ], - "track_total_hits": true, }, - "index": Array [ - "myIndex", - "myIndex", - ], + "index": "myIndex", } `; diff --git a/x-pack/legacy/plugins/apm/server/lib/traces/get_trace.ts b/x-pack/legacy/plugins/apm/server/lib/traces/get_trace.ts index e38ce56edde80..a1b9270e0d7b3 100644 --- a/x-pack/legacy/plugins/apm/server/lib/traces/get_trace.ts +++ b/x-pack/legacy/plugins/apm/server/lib/traces/get_trace.ts @@ -5,16 +5,15 @@ */ import { PromiseReturnType } from '../../../typings/common'; -import { getTraceErrorsPerTransaction } from '../errors/get_trace_errors_per_transaction'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getTraceItems } from './get_trace_items'; export type TraceAPIResponse = PromiseReturnType; export async function getTrace(traceId: string, setup: Setup & SetupTimeRange) { - const [trace, errorsPerTransaction] = await Promise.all([ - getTraceItems(traceId, setup), - getTraceErrorsPerTransaction(traceId, setup) - ]); + const { errorsPerTransaction, ...trace } = await getTraceItems( + traceId, + setup + ); return { trace, diff --git a/x-pack/legacy/plugins/apm/server/lib/traces/get_trace_items.ts b/x-pack/legacy/plugins/apm/server/lib/traces/get_trace_items.ts index 8118b6acaee39..9d3e0d6db7f16 100644 --- a/x-pack/legacy/plugins/apm/server/lib/traces/get_trace_items.ts +++ b/x-pack/legacy/plugins/apm/server/lib/traces/get_trace_items.ts @@ -9,13 +9,20 @@ import { TRACE_ID, PARENT_ID, TRANSACTION_DURATION, - SPAN_DURATION + SPAN_DURATION, + TRANSACTION_ID, + ERROR_LOG_LEVEL } from '../../../common/elasticsearch_fieldnames'; import { Span } from '../../../typings/es_schemas/ui/Span'; import { Transaction } from '../../../typings/es_schemas/ui/Transaction'; import { APMError } from '../../../typings/es_schemas/ui/APMError'; import { rangeFilter } from '../helpers/range_filter'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { PromiseValueType } from '../../../typings/common'; + +interface ErrorsPerTransaction { + [transactionId: string]: number; +} export async function getTraceItems( traceId: string, @@ -23,8 +30,36 @@ export async function getTraceItems( ) { const { start, end, client, config, indices } = setup; const maxTraceItems = config['xpack.apm.ui.maxTraceItems']; + const excludedLogLevels = ['debug', 'info', 'warning']; - const params = { + const errorResponsePromise = client.search({ + index: indices['apm_oss.errorIndices'], + body: { + size: maxTraceItems, + query: { + bool: { + filter: [ + { term: { [TRACE_ID]: traceId } }, + { term: { [PROCESSOR_EVENT]: 'error' } }, + { range: rangeFilter(start, end) } + ], + must_not: { terms: { [ERROR_LOG_LEVEL]: excludedLogLevels } } + } + }, + aggs: { + by_transaction_id: { + terms: { + field: TRANSACTION_ID, + size: maxTraceItems, + // high cardinality + execution_hint: 'map' as const + } + } + } + } + }); + + const traceResponsePromise = client.search({ index: [ indices['apm_oss.spanIndices'], indices['apm_oss.transactionIndices'] @@ -35,7 +70,7 @@ export async function getTraceItems( bool: { filter: [ { term: { [TRACE_ID]: traceId } }, - { terms: { [PROCESSOR_EVENT]: ['span', 'transaction', 'error'] } }, + { terms: { [PROCESSOR_EVENT]: ['span', 'transaction'] } }, { range: rangeFilter(start, end) } ], should: { @@ -50,12 +85,43 @@ export async function getTraceItems( ], track_total_hits: true } - }; + }); + + const [errorResponse, traceResponse]: [ + // explicit intermediary types to avoid TS "excessively deep" error + PromiseValueType, + PromiseValueType + // @ts-ignore + ] = await Promise.all([errorResponsePromise, traceResponsePromise]); + + const exceedsMax = traceResponse.hits.total.value > maxTraceItems; - const resp = await client.search(params); + const items = (traceResponse.hits.hits as Array<{ + _source: Transaction | Span; + }>).map(hit => hit._source); + + const errorFrequencies: { + errorsPerTransaction: ErrorsPerTransaction; + errorDocs: APMError[]; + } = { + errorDocs: errorResponse.hits.hits.map( + ({ _source }) => _source as APMError + ), + errorsPerTransaction: + errorResponse.aggregations?.by_transaction_id.buckets.reduce( + (acc, current) => { + return { + ...acc, + [current.key]: current.doc_count + }; + }, + {} as ErrorsPerTransaction + ) ?? {} + }; return { - items: resp.hits.hits.map(hit => hit._source), - exceedsMax: resp.hits.total.value > maxTraceItems + items, + exceedsMax, + ...errorFrequencies }; } diff --git a/x-pack/legacy/plugins/apm/typings/common.d.ts b/x-pack/legacy/plugins/apm/typings/common.d.ts index b9064980bd657..1e718f818246c 100644 --- a/x-pack/legacy/plugins/apm/typings/common.d.ts +++ b/x-pack/legacy/plugins/apm/typings/common.d.ts @@ -22,6 +22,10 @@ type AllowUnknownObjectProperties = T extends object } : T; +export type PromiseValueType = Value extends Promise + ? Value + : Value; + export type PromiseReturnType = Func extends ( ...args: any[] ) => Promise From 077d24de10a79f8a747782d54d1c574a876bed32 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 3 Feb 2020 13:14:42 -0800 Subject: [PATCH 06/10] Closes #55502. Fixes firefox SVG error by preventing tooltip and marks (#56578) from rendering if there are no values in the Plot. Co-authored-by: Elastic Machine --- .../components/shared/charts/CustomPlot/InteractivePlot.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js index bc758c7288e96..69b73ad6a0c0f 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js +++ b/x-pack/legacy/plugins/apm/public/components/shared/charts/CustomPlot/InteractivePlot.js @@ -66,9 +66,13 @@ class InteractivePlot extends PureComponent { const tooltipPoints = this.getTooltipPoints(hoverX); const markPoints = this.getMarkPoints(hoverX); - const { x, yTickValues } = plotValues; + const { x, xTickValues, yTickValues } = plotValues; const yValueMiddle = yTickValues[1]; + if (isEmpty(xTickValues)) { + return ; + } + return ( {hoverX && ( From e08df006c3a3d49ec8612c35298d2a68be12fced Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 3 Feb 2020 13:19:23 -0800 Subject: [PATCH 07/10] [APM] Fix initial error sort field (#56577) * Closes #52840. Changes the initial sorting field from `latestOccurrenceAt` -> `occurrenceCount` * update jest snapshots Co-authored-by: Elastic Machine --- .../__test__/__snapshots__/List.test.tsx.snap | 68 +++++++++---------- .../app/ErrorGroupOverview/List/index.tsx | 2 +- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index 492d28206f3dd..a45357121354f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -43,7 +43,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` } initialPageSize={25} initialSortDirection="desc" - initialSortField="latestOccurrenceAt" + initialSortField="occurrenceCount" items={Array []} noItemsMessage="No errors were found" sortItems={false} @@ -190,7 +190,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = ` List should render empty state 1`] = ` } >