From bb6fd0bf4ff8e0a501747aef4b399e997cc25e6b Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 2 Mar 2020 15:15:44 -0700 Subject: [PATCH 01/22] [kbn/optimizer] fix ui/* url rewrites in dist (#58627) * [kbn/optimizer] fix ui/* url rewrites in dist * add tests to verify styles are built correctly and ui-rewrites are happening * clarify change to dirs creation * create tested & shared parsePath helper * update renovate config * split implementation of parsePath for dir and file paths * switch to valid css property Co-authored-by: Elastic Machine --- package.json | 1 + .../mock_repo/plugins/bar/public/index.ts | 1 + .../plugins/bar/public/legacy/styles.scss | 4 + .../mock_repo/src/legacy/ui/public/icon.svg | 1 + .../ui/public/styles/_styling_constants.scss | 1 + .../basic_optimization.test.ts.snap | 527 +----------------- .../basic_optimization.test.ts | 56 +- .../__snapshots__/parse_path.test.ts.snap | 156 ++++++ .../src/worker/parse_path.test.ts | 20 +- .../kbn-optimizer/src/worker/parse_path.ts | 43 ++ .../kbn-optimizer/src/worker/run_compilers.ts | 16 +- .../src/worker/webpack.config.ts | 7 +- renovate.json5 | 8 + yarn.lock | 5 + 14 files changed, 299 insertions(+), 547 deletions(-) create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/icon.svg create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/styles/_styling_constants.scss create mode 100644 packages/kbn-optimizer/src/worker/__snapshots__/parse_path.test.ts.snap rename typings/normalize_path/index.d.ts => packages/kbn-optimizer/src/worker/parse_path.test.ts (57%) create mode 100644 packages/kbn-optimizer/src/worker/parse_path.ts diff --git a/package.json b/package.json index 5db93e5ab5ab989..e727d87a83c537a 100644 --- a/package.json +++ b/package.json @@ -349,6 +349,7 @@ "@types/mustache": "^0.8.31", "@types/node": "^10.12.27", "@types/node-forge": "^0.9.0", + "@types/normalize-path": "^3.0.0", "@types/numeral": "^0.0.26", "@types/opn": "^5.1.0", "@types/pegjs": "^0.10.1", diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts index 66fa55479f3b9e6..817c4796562e82e 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/index.ts @@ -17,6 +17,7 @@ * under the License. */ +import './legacy/styles.scss'; import { fooLibFn } from '../../foo/public/index'; export * from './lib'; export { fooLibFn }; diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss new file mode 100644 index 000000000000000..e71a2d485a2f85f --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/bar/public/legacy/styles.scss @@ -0,0 +1,4 @@ +body { + width: $globalStyleConstant; + background-image: url("ui/icon.svg"); +} diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/icon.svg b/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/icon.svg new file mode 100644 index 000000000000000..ae7d5b958bbadc3 --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/styles/_styling_constants.scss b/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/styles/_styling_constants.scss new file mode 100644 index 000000000000000..83995ca65211bdd --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/src/legacy/ui/public/styles/_styling_constants.scss @@ -0,0 +1 @@ +$globalStyleConstant: 10; diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 706f79978beee4d..1a974d3e81092bf 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -5,553 +5,56 @@ OptimizerConfig { "bundles": Array [ Bundle { "cache": BundleCache { - "path": /plugins/bar/target/public/.kbn-optimizer-cache, + "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public/.kbn-optimizer-cache, "state": undefined, }, - "contextDir": /plugins/bar, + "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar, "entry": "./public/index", "id": "bar", - "outputDir": /plugins/bar/target/public, - "sourceRoot": , + "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/target/public, + "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "type": "plugin", }, Bundle { "cache": BundleCache { - "path": /plugins/foo/target/public/.kbn-optimizer-cache, + "path": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public/.kbn-optimizer-cache, "state": undefined, }, - "contextDir": /plugins/foo, + "contextDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, "entry": "./public/index", "id": "foo", - "outputDir": /plugins/foo/target/public, - "sourceRoot": , + "outputDir": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/target/public, + "sourceRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "type": "plugin", }, ], "cache": true, - "dist": false, + "dist": true, "inspectWorkers": false, "maxWorkerCount": 1, "plugins": Array [ Object { - "directory": /plugins/bar, + "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar, "id": "bar", "isUiPlugin": true, }, Object { - "directory": /plugins/baz, + "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/baz, "id": "baz", "isUiPlugin": false, }, Object { - "directory": /plugins/foo, + "directory": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo, "id": "foo", "isUiPlugin": true, }, ], "profileWebpack": false, - "repoRoot": , + "repoRoot": /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo, "watch": false, } `; -exports[`builds expected bundles, saves bundle counts to metadata: bar bundle 1`] = ` -"var __kbnBundles__ = typeof __kbnBundles__ === \\"object\\" ? __kbnBundles__ : {}; __kbnBundles__[\\"plugin/bar\\"] = -/******/ (function(modules) { // webpackBootstrap -/******/ // The module cache -/******/ var installedModules = {}; -/******/ -/******/ // The require function -/******/ function __webpack_require__(moduleId) { -/******/ -/******/ // Check if module is in cache -/******/ if(installedModules[moduleId]) { -/******/ return installedModules[moduleId].exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = installedModules[moduleId] = { -/******/ i: moduleId, -/******/ l: false, -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); -/******/ -/******/ // Flag the module as loaded -/******/ module.l = true; -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/******/ -/******/ // expose the modules object (__webpack_modules__) -/******/ __webpack_require__.m = modules; -/******/ -/******/ // expose the module cache -/******/ __webpack_require__.c = installedModules; -/******/ -/******/ // define getter function for harmony exports -/******/ __webpack_require__.d = function(exports, name, getter) { -/******/ if(!__webpack_require__.o(exports, name)) { -/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); -/******/ } -/******/ }; -/******/ -/******/ // define __esModule on exports -/******/ __webpack_require__.r = function(exports) { -/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { -/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); -/******/ } -/******/ Object.defineProperty(exports, '__esModule', { value: true }); -/******/ }; -/******/ -/******/ // create a fake namespace object -/******/ // mode & 1: value is a module id, require it -/******/ // mode & 2: merge all properties of value into the ns -/******/ // mode & 4: return value when already ns object -/******/ // mode & 8|1: behave like require -/******/ __webpack_require__.t = function(value, mode) { -/******/ if(mode & 1) value = __webpack_require__(value); -/******/ if(mode & 8) return value; -/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; -/******/ var ns = Object.create(null); -/******/ __webpack_require__.r(ns); -/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); -/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); -/******/ return ns; -/******/ }; -/******/ -/******/ // getDefaultExport function for compatibility with non-harmony modules -/******/ __webpack_require__.n = function(module) { -/******/ var getter = module && module.__esModule ? -/******/ function getDefault() { return module['default']; } : -/******/ function getModuleExports() { return module; }; -/******/ __webpack_require__.d(getter, 'a', getter); -/******/ return getter; -/******/ }; -/******/ -/******/ // Object.prototype.hasOwnProperty.call -/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; -/******/ -/******/ // __webpack_public_path__ -/******/ __webpack_require__.p = \\"__REPLACE_WITH_PUBLIC_PATH__\\"; -/******/ -/******/ -/******/ // Load entry module and return exports -/******/ return __webpack_require__(__webpack_require__.s = \\"./public/index.ts\\"); -/******/ }) -/************************************************************************/ -/******/ ({ +exports[`builds expected bundles, saves bundle counts to metadata: bar bundle 1`] = `"var __kbnBundles__=typeof __kbnBundles__===\\"object\\"?__kbnBundles__:{};__kbnBundles__[\\"plugin/bar\\"]=function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"__REPLACE_WITH_PUBLIC_PATH__\\";return __webpack_require__(__webpack_require__.s=4)}([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i { await del(TMP_DIR); @@ -51,20 +51,25 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { repoRoot: MOCK_REPO_DIR, pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')], maxWorkerCount: 1, + dist: true, }); expect(config).toMatchSnapshot('OptimizerConfig'); - const msgs = await runOptimizer(config) - .pipe( - tap(state => { - if (state.event?.type === 'worker stdio') { - // eslint-disable-next-line no-console - console.log('worker', state.event.stream, state.event.chunk.toString('utf8')); + const log = new ToolingLog({ + level: 'error', + writeTo: { + write(chunk) { + if (chunk.endsWith('\n')) { + chunk = chunk.slice(0, -1); } - }), - toArray() - ) + // eslint-disable-next-line no-console + console.error(chunk); + }, + }, + }); + const msgs = await runOptimizer(config) + .pipe(logOptimizerState(log, config), toArray()) .toPromise(); const assert = (statement: string, truth: boolean, altStates?: OptimizerUpdate[]) => { @@ -133,23 +138,31 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { expect(foo.cache.getModuleCount()).toBe(3); expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ - /plugins/foo/public/ext.ts, - /plugins/foo/public/index.ts, - /plugins/foo/public/lib.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, ] `); const bar = config.bundles.find(b => b.id === 'bar')!; expect(bar).toBeTruthy(); bar.cache.refresh(); - expect(bar.cache.getModuleCount()).toBe(5); + expect(bar.cache.getModuleCount()).toBe( + // code + styles + style/css-loader runtime + 14 + ); + expect(bar.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ - /plugins/foo/public/ext.ts, - /plugins/foo/public/index.ts, - /plugins/foo/public/lib.ts, - /plugins/bar/public/index.ts, - /plugins/bar/public/lib.ts, + /node_modules/css-loader/package.json, + /node_modules/style-loader/package.json, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/styles.scss, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/lib.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, + /packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/src/legacy/ui/public/icon.svg, ] `); }); @@ -159,6 +172,7 @@ it('uses cache on second run and exist cleanly', async () => { repoRoot: MOCK_REPO_DIR, pluginScanDirs: [Path.resolve(MOCK_REPO_DIR, 'plugins')], maxWorkerCount: 1, + dist: true, }); const msgs = await runOptimizer(config) diff --git a/packages/kbn-optimizer/src/worker/__snapshots__/parse_path.test.ts.snap b/packages/kbn-optimizer/src/worker/__snapshots__/parse_path.test.ts.snap new file mode 100644 index 000000000000000..2973ac116d6bd1e --- /dev/null +++ b/packages/kbn-optimizer/src/worker/__snapshots__/parse_path.test.ts.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseDirPath() parses / 1`] = ` +Object { + "dirs": Array [], + "filename": undefined, + "root": "/", +} +`; + +exports[`parseDirPath() parses /foo 1`] = ` +Object { + "dirs": Array [ + "foo", + ], + "filename": undefined, + "root": "/", +} +`; + +exports[`parseDirPath() parses /foo/bar/baz 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + "baz", + ], + "filename": undefined, + "root": "/", +} +`; + +exports[`parseDirPath() parses /foo/bar/baz/ 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + "baz", + ], + "filename": undefined, + "root": "/", +} +`; + +exports[`parseDirPath() parses c:\\ 1`] = ` +Object { + "dirs": Array [], + "filename": undefined, + "root": "c:", +} +`; + +exports[`parseDirPath() parses c:\\foo 1`] = ` +Object { + "dirs": Array [ + "foo", + ], + "filename": undefined, + "root": "c:", +} +`; + +exports[`parseDirPath() parses c:\\foo\\bar\\baz 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + "baz", + ], + "filename": undefined, + "root": "c:", +} +`; + +exports[`parseDirPath() parses c:\\foo\\bar\\baz\\ 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + "baz", + ], + "filename": undefined, + "root": "c:", +} +`; + +exports[`parseFilePath() parses /foo 1`] = ` +Object { + "dirs": Array [], + "filename": "foo", + "root": "/", +} +`; + +exports[`parseFilePath() parses /foo/bar/baz 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz", + "root": "/", +} +`; + +exports[`parseFilePath() parses /foo/bar/baz.json 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz.json", + "root": "/", +} +`; + +exports[`parseFilePath() parses c:/foo/bar/baz.json 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz.json", + "root": "c:", +} +`; + +exports[`parseFilePath() parses c:\\foo 1`] = ` +Object { + "dirs": Array [], + "filename": "foo", + "root": "c:", +} +`; + +exports[`parseFilePath() parses c:\\foo\\bar\\baz 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz", + "root": "c:", +} +`; + +exports[`parseFilePath() parses c:\\foo\\bar\\baz.json 1`] = ` +Object { + "dirs": Array [ + "foo", + "bar", + ], + "filename": "baz.json", + "root": "c:", +} +`; diff --git a/typings/normalize_path/index.d.ts b/packages/kbn-optimizer/src/worker/parse_path.test.ts similarity index 57% rename from typings/normalize_path/index.d.ts rename to packages/kbn-optimizer/src/worker/parse_path.test.ts index 31e064ca63d9033..72197e8c8fb07af 100644 --- a/typings/normalize_path/index.d.ts +++ b/packages/kbn-optimizer/src/worker/parse_path.test.ts @@ -17,8 +17,20 @@ * under the License. */ -declare function NormalizePath(path: string, stripTrailing?: boolean): string; +import { parseFilePath, parseDirPath } from './parse_path'; -declare module 'normalize-path' { - export = NormalizePath; -} +const DIRS = ['/', '/foo/bar/baz/', 'c:\\', 'c:\\foo\\bar\\baz\\']; +const AMBIGUOUS = ['/foo', '/foo/bar/baz', 'c:\\foo', 'c:\\foo\\bar\\baz']; +const FILES = ['/foo/bar/baz.json', 'c:/foo/bar/baz.json', 'c:\\foo\\bar\\baz.json']; + +describe('parseFilePath()', () => { + it.each([...FILES, ...AMBIGUOUS])('parses %s', path => { + expect(parseFilePath(path)).toMatchSnapshot(); + }); +}); + +describe('parseDirPath()', () => { + it.each([...DIRS, ...AMBIGUOUS])('parses %s', path => { + expect(parseDirPath(path)).toMatchSnapshot(); + }); +}); diff --git a/packages/kbn-optimizer/src/worker/parse_path.ts b/packages/kbn-optimizer/src/worker/parse_path.ts new file mode 100644 index 000000000000000..88152df55b84f47 --- /dev/null +++ b/packages/kbn-optimizer/src/worker/parse_path.ts @@ -0,0 +1,43 @@ +/* + * 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 normalizePath from 'normalize-path'; + +/** + * Parse an absolute path, supporting normalized paths from webpack, + * into a list of directories and root + */ +export function parseDirPath(path: string) { + const filePath = parseFilePath(path); + return { + ...filePath, + dirs: [...filePath.dirs, ...(filePath.filename ? [filePath.filename] : [])], + filename: undefined, + }; +} + +export function parseFilePath(path: string) { + const normalized = normalizePath(path); + const [root, ...others] = normalized.split('/'); + return { + root: root === '' ? '/' : root, + dirs: others.slice(0, -1), + filename: others[others.length - 1] || undefined, + }; +} diff --git a/packages/kbn-optimizer/src/worker/run_compilers.ts b/packages/kbn-optimizer/src/worker/run_compilers.ts index 7dcce8a0fae8d81..7a8097fd2b2c799 100644 --- a/packages/kbn-optimizer/src/worker/run_compilers.ts +++ b/packages/kbn-optimizer/src/worker/run_compilers.ts @@ -27,9 +27,10 @@ import webpack, { Stats } from 'webpack'; import * as Rx from 'rxjs'; import { mergeMap, map, mapTo, takeUntil } from 'rxjs/operators'; -import { CompilerMsgs, CompilerMsg, maybeMap, Bundle, WorkerConfig } from '../common'; +import { CompilerMsgs, CompilerMsg, maybeMap, Bundle, WorkerConfig, ascending } from '../common'; import { getWebpackConfig } from './webpack.config'; import { isFailureStats, failedStatsToErrorMessage } from './webpack_helpers'; +import { parseFilePath } from './parse_path'; import { isExternalModule, isNormalModule, @@ -108,20 +109,19 @@ const observeCompiler = ( for (const module of normalModules) { const path = getModulePath(module); + const parsedPath = parseFilePath(path); - const parsedPath = Path.parse(path); - const dirSegments = parsedPath.dir.split(Path.sep); - if (!dirSegments.includes('node_modules')) { + if (!parsedPath.dirs.includes('node_modules')) { referencedFiles.add(path); continue; } - const nmIndex = dirSegments.lastIndexOf('node_modules'); - const isScoped = dirSegments[nmIndex + 1].startsWith('@'); + const nmIndex = parsedPath.dirs.lastIndexOf('node_modules'); + const isScoped = parsedPath.dirs[nmIndex + 1].startsWith('@'); referencedFiles.add( Path.join( parsedPath.root, - ...dirSegments.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), + ...parsedPath.dirs.slice(0, nmIndex + 1 + (isScoped ? 2 : 1)), 'package.json' ) ); @@ -146,7 +146,7 @@ const observeCompiler = ( optimizerCacheKey: workerConfig.optimizerCacheKey, cacheKey: bundle.createCacheKey(files, mtimes), moduleCount: normalModules.length, - files, + files: files.sort(ascending(f => f)), }); return compilerMsgs.compilerSuccess({ diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 3c6ae78bc4d911b..5d8ef7626f6305f 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -30,6 +30,7 @@ import { CleanWebpackPlugin } from 'clean-webpack-plugin'; import * as SharedDeps from '@kbn/ui-shared-deps'; import { Bundle, WorkerConfig } from '../common'; +import { parseDirPath } from './parse_path'; const IS_CODE_COVERAGE = !!process.env.CODE_COVERAGE; const ISTANBUL_PRESET_PATH = require.resolve('@kbn/babel-preset/istanbul_preset'); @@ -135,7 +136,7 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { } // manually force ui/* urls in legacy styles to resolve to ui/legacy/public - if (uri.startsWith('ui/') && base.split(Path.sep).includes('legacy')) { + if (uri.startsWith('ui/') && parseDirPath(base).dirs.includes('legacy')) { return Path.resolve( worker.repoRoot, 'src/legacy/ui/public', @@ -150,7 +151,9 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { { loader: 'sass-loader', options: { - sourceMap: !worker.dist, + // must always be enabled as long as we're using the `resolve-url-loader` to + // rewrite `ui/*` urls. They're dropped by subsequent loaders though + sourceMap: true, prependData(loaderContext: webpack.loader.LoaderContext) { return `@import ${stringifyRequest( loaderContext, diff --git a/renovate.json5 b/renovate.json5 index 58a64a5d0f9679f..ca2cd2e6bcd9329 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -665,6 +665,14 @@ '@types/nodemailer', ], }, + { + groupSlug: 'normalize-path', + groupName: 'normalize-path related packages', + packageNames: [ + 'normalize-path', + '@types/normalize-path', + ], + }, { groupSlug: 'numeral', groupName: 'numeral related packages', diff --git a/yarn.lock b/yarn.lock index 338d516a796e1e2..e4d5dcce5bca08c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4864,6 +4864,11 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== +"@types/normalize-path@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/normalize-path/-/normalize-path-3.0.0.tgz#bb5c46cab77b93350b4cf8d7ff1153f47189ae31" + integrity sha512-Nd8y/5t/7CRakPYiyPzr/IAfYusy1FkcZYFEAcoMZkwpJv2n4Wm+olW+e7xBdHEXhOnWdG9ddbar0gqZWS4x5Q== + "@types/numeral@^0.0.25": version "0.0.25" resolved "https://registry.yarnpkg.com/@types/numeral/-/numeral-0.0.25.tgz#b6f55062827a4787fe4ab151cf3412a468e65271" From 48a33abdeed147932401cc3a24c36669189f67f3 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Mon, 2 Mar 2020 15:51:33 -0700 Subject: [PATCH 02/22] Remove appBasePath from docs + add mock for AppMountParameters (#58775) --- ...lugin-public.appmountparameters.history.md | 1 - ...in-public.appmountparameters.onappleave.md | 4 +-- src/core/CONVENTIONS.md | 4 +-- src/core/TESTING.md | 14 ++++++----- src/core/public/application/types.ts | 5 ++-- src/core/public/mocks.ts | 25 ++++++++++++++++++- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.history.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.history.md index 9a3fa1a1bb48a61..f22e70b0e7bee20 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.history.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.history.md @@ -44,7 +44,6 @@ import { MyPluginDepsStart } from './plugin'; export renderApp = ({ element, history }: AppMountParameters) => { ReactDOM.render( - // pass `appBasePath` to `basename` , diff --git a/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md b/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md index 283ae34f14c5453..6c5b89ffda05be6 100644 --- a/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md +++ b/docs/development/core/public/kibana-plugin-public.appmountparameters.onappleave.md @@ -26,7 +26,7 @@ import { BrowserRouter, Route } from 'react-router-dom'; import { CoreStart, AppMountParams } from 'src/core/public'; import { MyPluginDepsStart } from './plugin'; -export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => { +export renderApp = ({ element, history, onAppLeave }: AppMountParams) => { const { renderApp, hasUnsavedChanges } = await import('./application'); onAppLeave(actions => { if(hasUnsavedChanges()) { @@ -34,7 +34,7 @@ export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => { } return actions.default(); }); - return renderApp(params); + return renderApp({ element, history }); } ``` diff --git a/src/core/CONVENTIONS.md b/src/core/CONVENTIONS.md index 2769079757bc385..0f592d108c5617b 100644 --- a/src/core/CONVENTIONS.md +++ b/src/core/CONVENTIONS.md @@ -148,8 +148,8 @@ import { MyAppRoot } from './components/app.ts'; /** * This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle. */ -export const renderApp = (core: CoreStart, deps: MyPluginDepsStart, { element, appBasePath }: AppMountParams) => { - ReactDOM.render(, element); +export const renderApp = (core: CoreStart, deps: MyPluginDepsStart, { element, history }: AppMountParams) => { + ReactDOM.render(, element); return () => ReactDOM.unmountComponentAtNode(element); } ``` diff --git a/src/core/TESTING.md b/src/core/TESTING.md index 9abc2bb77d7d122..cb38dac0e20ce7f 100644 --- a/src/core/TESTING.md +++ b/src/core/TESTING.md @@ -453,7 +453,7 @@ describe('Plugin', () => { const [coreStartMock, startDepsMock] = await coreSetup.getStartServices(); const unmountMock = jest.fn(); renderAppMock.mockReturnValue(unmountMock); - const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const params = coreMock.createAppMountParamters('/fake/base/path'); new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup); // Grab registered mount function @@ -478,7 +478,7 @@ import ReactDOM from 'react-dom'; import { AppMountParams, CoreStart } from 'src/core/public'; import { AppRoot } from './components/app_root'; -export const renderApp = ({ element, appBasePath }: AppMountParams, core: CoreStart, plugins: MyPluginDepsStart) => { +export const renderApp = ({ element, history }: AppMountParams, core: CoreStart, plugins: MyPluginDepsStart) => { // Hide the chrome while this app is mounted for a full screen experience core.chrome.setIsVisible(false); @@ -491,7 +491,7 @@ export const renderApp = ({ element, appBasePath }: AppMountParams, core: CoreSt // Render app ReactDOM.render( - , + , element ); @@ -512,12 +512,14 @@ In testing `renderApp` you should be verifying that: ```typescript /** public/application.test.ts */ +import { createMemoryHistory } from 'history'; +import { ScopedHistory } from 'src/core/public'; import { coreMock } from 'src/core/public/mocks'; import { renderApp } from './application'; describe('renderApp', () => { it('mounts and unmounts UI', () => { - const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const params = coreMock.createAppMountParamters('/fake/base/path'); const core = coreMock.createStart(); // Verify some expected DOM element is rendered into the element @@ -529,7 +531,7 @@ describe('renderApp', () => { }); it('unsubscribes from uiSettings', () => { - const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const params = coreMock.createAppMountParamters('/fake/base/path'); const core = coreMock.createStart(); // Create a fake Subject you can use to monitor observers const settings$ = new Subject(); @@ -544,7 +546,7 @@ describe('renderApp', () => { }); it('resets chrome visibility', () => { - const params = { element: document.createElement('div'), appBasePath: '/fake/base/path' }; + const params = coreMock.createAppMountParamters('/fake/base/path'); const core = coreMock.createStart(); // Verify stateful Core API was called on mount diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index facb818c60ff941..318afb652999ef0 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -347,7 +347,6 @@ export interface AppMountParameters { * * export renderApp = ({ element, history }: AppMountParameters) => { * ReactDOM.render( - * // pass `appBasePath` to `basename` * * * , @@ -429,7 +428,7 @@ export interface AppMountParameters { * import { CoreStart, AppMountParams } from 'src/core/public'; * import { MyPluginDepsStart } from './plugin'; * - * export renderApp = ({ appBasePath, element, onAppLeave }: AppMountParams) => { + * export renderApp = ({ element, history, onAppLeave }: AppMountParams) => { * const { renderApp, hasUnsavedChanges } = await import('./application'); * onAppLeave(actions => { * if(hasUnsavedChanges()) { @@ -437,7 +436,7 @@ export interface AppMountParameters { * } * return actions.default(); * }); - * return renderApp(params); + * return renderApp({ element, history }); * } * ``` */ diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 8ea672890ca2976..c860e9de8334e9b 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -16,9 +16,15 @@ * specific language governing permissions and limitations * under the License. */ +import { createMemoryHistory } from 'history'; + +// Only import types from '.' to avoid triggering default Jest mocks. +import { CoreContext, PluginInitializerContext, AppMountParameters } from '.'; +// Import values from their individual modules instead. +import { ScopedHistory } from './application'; + import { applicationServiceMock } from './application/application_service.mock'; import { chromeServiceMock } from './chrome/chrome_service.mock'; -import { CoreContext, PluginInitializerContext } from '.'; import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; import { httpServiceMock } from './http/http_service.mock'; @@ -139,10 +145,27 @@ function createStorageMock() { return storageMock; } +function createAppMountParametersMock(appBasePath = '') { + // Assemble an in-memory history mock using the provided basePath + const rawHistory = createMemoryHistory(); + rawHistory.push(appBasePath); + const history = new ScopedHistory(rawHistory, appBasePath); + + const params: jest.Mocked = { + appBasePath, + element: document.createElement('div'), + history, + onAppLeave: jest.fn(), + }; + + return params; +} + export const coreMock = { createCoreContext, createSetup: createCoreSetupMock, createStart: createCoreStartMock, createPluginInitializerContext: pluginInitializerContextMock, createStorage: createStorageMock, + createAppMountParamters: createAppMountParametersMock, }; From 90b3678dffa20ff7e0ab806861bfa64d46d86cb9 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Mon, 2 Mar 2020 16:04:29 -0700 Subject: [PATCH 03/22] [SIEM] [Case] Comments to case view (#58315) --- .../edit_data_provider/translations.ts | 2 +- .../components/formatted_date/index.tsx | 29 +++ .../editable_title.test.tsx.snap | 2 +- .../components/header_page/editable_title.tsx | 64 ++++-- .../components/header_page/translations.ts | 4 +- .../components/markdown_editor/constants.ts | 7 + .../components/markdown_editor/form.tsx | 58 ++++++ .../components/markdown_editor/index.tsx | 121 +++++++++++ .../markdown_editor/translations.ts | 18 ++ .../components/navigation/index.test.tsx | 4 +- .../siem/public/containers/case/api.ts | 38 +++- .../siem/public/containers/case/constants.ts | 2 +- .../siem/public/containers/case/types.ts | 28 ++- .../public/containers/case/use_get_case.tsx | 4 +- .../public/containers/case/use_post_case.tsx | 3 +- .../containers/case/use_post_comment.tsx | 97 +++++++++ .../containers/case/use_update_case.tsx | 68 +++--- .../containers/case/use_update_comment.tsx | 92 ++++++++ .../case/components/add_comment/index.tsx | 73 +++++++ .../case/components/add_comment/schema.tsx | 20 ++ .../components/all_cases/__mock__/index.tsx | 10 + .../case/components/all_cases/columns.tsx | 2 +- .../components/case_view/__mock__/index.tsx | 29 ++- .../case/components/case_view/index.test.tsx | 41 +++- .../pages/case/components/case_view/index.tsx | 196 ++++++------------ .../case/components/case_view/translations.ts | 12 ++ .../pages/case/components/create/index.tsx | 77 ++++--- .../pages/case/components/create/schema.tsx | 6 +- .../description_md_editor/index.tsx | 111 ---------- .../pages/case/components/tag_list/index.tsx | 68 +++--- .../pages/case/components/tag_list/schema.tsx | 2 +- .../components/user_action_tree/index.tsx | 139 ++++++++++--- .../user_action_tree/user_action_avatar.tsx | 18 ++ .../user_action_tree/user_action_item.tsx | 60 ++++++ .../user_action_tree/user_action_markdown.tsx | 89 ++++++++ .../user_action_tree/user_action_title.tsx | 70 +++++++ .../pages/case/components/user_list/index.tsx | 4 +- .../siem/public/pages/case/translations.ts | 38 +++- .../rules/components/add_item_form/index.tsx | 2 +- .../components/description_step/index.tsx | 2 +- .../rules/components/mitre/index.tsx | 2 +- .../rules/components/pick_timeline/index.tsx | 2 +- .../rules/components/query_bar/index.tsx | 2 +- .../components/schedule_item_form/index.tsx | 2 +- .../components/step_about_rule/index.tsx | 2 +- .../components/step_about_rule/schema.tsx | 2 +- .../components/step_define_rule/index.tsx | 2 +- .../components/step_define_rule/schema.tsx | 2 +- .../components/step_schedule_rule/index.tsx | 2 +- .../components/step_schedule_rule/schema.tsx | 2 +- .../detection_engine/rules/create/index.tsx | 2 +- .../detection_engine/rules/edit/index.tsx | 2 +- .../pages/detection_engine/rules/helpers.tsx | 2 +- .../pages/detection_engine/rules/types.ts | 2 +- .../siem/public/pages/home/translations.ts | 2 +- .../siem/public/{pages => }/shared_imports.ts | 8 +- .../api/__tests__/update_comment.test.ts | 19 ++ .../plugins/case/server/routes/api/schema.ts | 5 + .../case/server/routes/api/update_comment.ts | 36 +++- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 61 files changed, 1355 insertions(+), 455 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/markdown_editor/constants.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/markdown_editor/form.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/markdown_editor/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/markdown_editor/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_avatar.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx rename x-pack/legacy/plugins/siem/public/{pages => }/shared_imports.ts (52%) diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/translations.ts b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/translations.ts index dadd349096a5305..53d2ffa19732796 100644 --- a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/translations.ts @@ -18,7 +18,7 @@ export const FIELD = i18n.translate('xpack.siem.editDataProvider.fieldLabel', { defaultMessage: 'Field', }); -export const FIELD_PLACEHOLDER = i18n.translate('xpack.siem.editDataProvider.fieldPlaceholder', { +export const FIELD_PLACEHOLDER = i18n.translate('xpack.siem.editDataProvider.placeholder', { defaultMessage: 'Select a field', }); diff --git a/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx b/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx index f74ee995c965b29..d100f891820141c 100644 --- a/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/formatted_date/index.tsx @@ -125,3 +125,32 @@ export const FormattedRelativePreferenceDate = ({ value }: { value?: string | nu ); }; + +/** + * Renders a preceding label according to under/over one hour + */ + +export const FormattedRelativePreferenceLabel = ({ + value, + preferenceLabel, + relativeLabel, +}: { + value?: string | number | null; + preferenceLabel?: string | null; + relativeLabel?: string | null; +}) => { + if (value == null) { + return null; + } + const maybeDate = getMaybeDate(value); + if (!maybeDate.isValid()) { + return null; + } + return moment(maybeDate.toDate()) + .add(1, 'hours') + .isBefore(new Date()) ? ( + <>{preferenceLabel} + ) : ( + <>{relativeLabel} + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/header_page/__snapshots__/editable_title.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/header_page/__snapshots__/editable_title.test.tsx.snap index 30c70d7f5a2a66d..24b1756aade2e80 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_page/__snapshots__/editable_title.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/header_page/__snapshots__/editable_title.test.tsx.snap @@ -15,7 +15,7 @@ exports[`EditableTitle it renders 1`] = ` - css` margin-left: ${theme.eui.euiSize}; `} `; -StyledEuiButtonIcon.displayName = 'StyledEuiButtonIcon'; +const MySpinner = styled(EuiLoadingSpinner)` + ${({ theme }) => css` + margin-left: ${theme.eui.euiSize}; + `} +`; interface Props { isLoading: boolean; @@ -36,24 +41,30 @@ interface Props { const EditableTitleComponent: React.FC = ({ onSubmit, isLoading, title }) => { const [editMode, setEditMode] = useState(false); - const [changedTitle, onTitleChange] = useState(title); + const [changedTitle, onTitleChange] = useState(typeof title === 'string' ? title : ''); const onCancel = useCallback(() => setEditMode(false), []); const onClickEditIcon = useCallback(() => setEditMode(true), []); - const onClickSubmit = useCallback( - (newTitle: string): void => { - onSubmit(newTitle); - setEditMode(false); + const onClickSubmit = useCallback((): void => { + if (changedTitle !== title) { + onSubmit(changedTitle); + } + setEditMode(false); + }, [changedTitle, title]); + + const handleOnChange = useCallback( + (e: ChangeEvent) => { + onTitleChange(e.target.value); }, - [changedTitle] + [onTitleChange] ); return editMode ? ( onTitleChange(e.target.value)} + onChange={handleOnChange} value={`${changedTitle}`} data-test-subj="editable-title-input-field" /> @@ -61,17 +72,23 @@ const EditableTitleComponent: React.FC = ({ onSubmit, isLoading, title }) onClickSubmit(changedTitle as string)} + color="secondary" data-test-subj="editable-title-submit-btn" + fill + iconType="save" + onClick={onClickSubmit} + size="s" > - {i18n.SUBMIT} + {i18n.SAVE} - + {i18n.CANCEL} @@ -84,12 +101,15 @@ const EditableTitleComponent: React.FC = ({ onSubmit, isLoading, title }) </EuiFlexItem> <EuiFlexItem grow={false}> - <StyledEuiButtonIcon - aria-label={i18n.EDIT_TITLE_ARIA(title as string)} - iconType="pencil" - onClick={onClickEditIcon} - data-test-subj="editable-title-edit-icon" - /> + {isLoading && <MySpinner />} + {!isLoading && ( + <MyEuiButtonIcon + aria-label={i18n.EDIT_TITLE_ARIA(title as string)} + iconType="pencil" + onClick={onClickEditIcon} + data-test-subj="editable-title-edit-icon" + /> + )} </EuiFlexItem> </EuiFlexGroup> ); diff --git a/x-pack/legacy/plugins/siem/public/components/header_page/translations.ts b/x-pack/legacy/plugins/siem/public/components/header_page/translations.ts index 2bc2ac492b0b13c..764f1e5ac37312e 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_page/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/header_page/translations.ts @@ -6,8 +6,8 @@ import { i18n } from '@kbn/i18n'; -export const SUBMIT = i18n.translate('xpack.siem.header.editableTitle.submit', { - defaultMessage: 'Submit', +export const SAVE = i18n.translate('xpack.siem.header.editableTitle.save', { + defaultMessage: 'Save', }); export const CANCEL = i18n.translate('xpack.siem.header.editableTitle.cancel', { diff --git a/x-pack/legacy/plugins/siem/public/components/markdown_editor/constants.ts b/x-pack/legacy/plugins/siem/public/components/markdown_editor/constants.ts new file mode 100644 index 000000000000000..dc57de5252b3ee3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/markdown_editor/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const MARKDOWN_HELP_LINK = 'https://www.markdownguide.org/cheat-sheet/'; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown_editor/form.tsx b/x-pack/legacy/plugins/siem/public/components/markdown_editor/form.tsx new file mode 100644 index 000000000000000..3c5287a6fac2466 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/markdown_editor/form.tsx @@ -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 { EuiFormRow } from '@elastic/eui'; +import React, { useCallback } from 'react'; + +import { FieldHook, getFieldValidityAndErrorMessage } from '../../shared_imports'; +import { MarkdownEditor } from '.'; + +interface IMarkdownEditorForm { + dataTestSubj: string; + field: FieldHook; + idAria: string; + isDisabled: boolean; + placeholder?: string; + footerContentRight?: React.ReactNode; +} +export const MarkdownEditorForm = ({ + dataTestSubj, + field, + idAria, + isDisabled = false, + placeholder, + footerContentRight, +}: IMarkdownEditorForm) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + const handleContentChange = useCallback( + (newContent: string) => { + field.setValue(newContent); + }, + [field] + ); + + return ( + <EuiFormRow + label={field.label} + labelAppend={field.labelAppend} + helpText={field.helpText} + error={errorMessage} + isInvalid={isInvalid} + fullWidth + data-test-subj={dataTestSubj} + describedByIds={idAria ? [idAria] : undefined} + > + <MarkdownEditor + initialContent={field.value as string} + isDisabled={isDisabled} + footerContentRight={footerContentRight} + onChange={handleContentChange} + placeholder={placeholder} + /> + </EuiFormRow> + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown_editor/index.tsx b/x-pack/legacy/plugins/siem/public/components/markdown_editor/index.tsx new file mode 100644 index 000000000000000..8572b447cced8f1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/markdown_editor/index.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPanel, + EuiTabbedContent, + EuiTextArea, +} from '@elastic/eui'; +import React, { useEffect, useMemo, useState } from 'react'; +import styled, { css } from 'styled-components'; + +import { Markdown } from '../markdown'; +import * as i18n from './translations'; +import { MARKDOWN_HELP_LINK } from './constants'; + +const TextArea = styled(EuiTextArea)` + width: 100%; +`; + +const Container = styled(EuiPanel)` + ${({ theme }) => css` + padding: 0; + background: ${theme.eui.euiColorLightestShade}; + position: relative; + .euiTab { + padding: 10px; + } + .euiFormRow__labelWrapper { + position: absolute; + top: -${theme.eui.euiSizeL}; + } + .euiFormErrorText { + padding: 0 ${theme.eui.euiSizeM}; + } + `} +`; + +const Tabs = styled(EuiTabbedContent)` + width: 100%; +`; + +const Footer = styled(EuiFlexGroup)` + ${({ theme }) => css` + height: 41px; + padding: 0 ${theme.eui.euiSizeM}; + .euiLink { + font-size: ${theme.eui.euiSizeM}; + } + `} +`; + +const MarkdownContainer = styled(EuiPanel)` + min-height: 150px; + overflow: auto; +`; + +/** An input for entering a new case description */ +export const MarkdownEditor = React.memo<{ + placeholder?: string; + footerContentRight?: React.ReactNode; + initialContent: string; + isDisabled?: boolean; + onChange: (description: string) => void; +}>(({ placeholder, footerContentRight, initialContent, isDisabled = false, onChange }) => { + const [content, setContent] = useState(initialContent); + useEffect(() => { + onChange(content); + }, [content]); + const tabs = useMemo( + () => [ + { + id: 'comment', + name: i18n.MARKDOWN, + content: ( + <TextArea + onChange={e => { + setContent(e.target.value); + }} + aria-label={`markdown-editor-comment`} + fullWidth={true} + disabled={isDisabled} + placeholder={placeholder ?? ''} + spellCheck={false} + value={content} + /> + ), + }, + { + id: 'preview', + name: i18n.PREVIEW, + content: ( + <MarkdownContainer data-test-subj="markdown-container" paddingSize="s"> + <Markdown raw={content} /> + </MarkdownContainer> + ), + }, + ], + [content, isDisabled, placeholder] + ); + return ( + <Container> + <Tabs data-test-subj={`markdown-tabs`} size="s" tabs={tabs} initialSelectedTab={tabs[0]} /> + <Footer alignItems="center" gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiLink href={MARKDOWN_HELP_LINK} external target="_blank"> + {i18n.MARKDOWN_SYNTAX_HELP} + </EuiLink> + </EuiFlexItem> + {footerContentRight && <EuiFlexItem grow={false}>{footerContentRight}</EuiFlexItem>} + </Footer> + </Container> + ); +}); + +MarkdownEditor.displayName = 'MarkdownEditor'; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown_editor/translations.ts b/x-pack/legacy/plugins/siem/public/components/markdown_editor/translations.ts new file mode 100644 index 000000000000000..642c524c48be057 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/markdown_editor/translations.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const MARKDOWN_SYNTAX_HELP = i18n.translate('xpack.siem.markdownEditor.markdownInputHelp', { + defaultMessage: 'Markdown syntax help', +}); + +export const MARKDOWN = i18n.translate('xpack.siem.markdownEditor.markdown', { + defaultMessage: 'Markdown', +}); +export const PREVIEW = i18n.translate('xpack.siem.markdownEditor.preview', { + defaultMessage: 'Preview', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx index e1b3951a2317d9d..a821d310344d8d4 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/navigation/index.test.tsx @@ -70,7 +70,7 @@ describe('SIEM Navigation', () => { disabled: false, href: '#/link-to/case', id: 'case', - name: 'Case', + name: 'Cases', urlKey: 'case', }, detections: { @@ -163,7 +163,7 @@ describe('SIEM Navigation', () => { disabled: false, href: '#/link-to/case', id: 'case', - name: 'Case', + name: 'Cases', urlKey: 'case', }, detections: { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index bff3bfd62a85c05..f1d87ca58b44b88 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -5,12 +5,22 @@ */ import { KibanaServices } from '../../lib/kibana'; -import { FetchCasesProps, Case, NewCase, SortFieldCase, AllCases, CaseSnake } from './types'; +import { + AllCases, + Case, + CaseSnake, + Comment, + CommentSnake, + FetchCasesProps, + NewCase, + NewComment, + SortFieldCase, +} from './types'; import { throwIfNotOk } from '../../hooks/api/api'; import { CASES_URL } from './constants'; import { convertToCamelCase, convertAllCasesToCamel } from './utils'; -export const getCase = async (caseId: string, includeComments: boolean): Promise<Case> => { +export const getCase = async (caseId: string, includeComments: boolean = true): Promise<Case> => { const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}`, { method: 'GET', asResponse: true, @@ -72,3 +82,27 @@ export const updateCaseProperty = async ( await throwIfNotOk(response.response); return convertToCamelCase<Partial<CaseSnake>, Partial<Case>>(response.body!); }; + +export const createComment = async (newComment: NewComment, caseId: string): Promise<Comment> => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/${caseId}/comment`, { + method: 'POST', + asResponse: true, + body: JSON.stringify(newComment), + }); + await throwIfNotOk(response.response); + return convertToCamelCase<CommentSnake, Comment>(response.body!); +}; + +export const updateComment = async ( + commentId: string, + commentUpdate: string, + version: string +): Promise<Partial<Comment>> => { + const response = await KibanaServices.get().http.fetch(`${CASES_URL}/comment/${commentId}`, { + method: 'PATCH', + asResponse: true, + body: JSON.stringify({ comment: commentUpdate, version }), + }); + await throwIfNotOk(response.response); + return convertToCamelCase<Partial<CommentSnake>, Partial<Comment>>(response.body!); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/constants.ts b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts index c8d668527ae3290..031ba1c128a247f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/constants.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/constants.ts @@ -11,6 +11,6 @@ export const FETCH_FAILURE = 'FETCH_FAILURE'; export const FETCH_INIT = 'FETCH_INIT'; export const FETCH_SUCCESS = 'FETCH_SUCCESS'; export const POST_NEW_CASE = 'POST_NEW_CASE'; -export const UPDATE_CASE_PROPERTY = 'UPDATE_CASE_PROPERTY'; +export const POST_NEW_COMMENT = 'POST_NEW_COMMENT'; export const UPDATE_FILTER_OPTIONS = 'UPDATE_FILTER_OPTIONS'; export const UPDATE_QUERY_PARAMS = 'UPDATE_QUERY_PARAMS'; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 1aea0b0f50a8936..75ed6f7c2366ddc 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -14,8 +14,31 @@ export interface NewCase extends FormData { title: string; } +export interface NewComment extends FormData { + comment: string; +} + +export interface CommentSnake { + comment_id: string; + created_at: string; + created_by: ElasticUserSnake; + comment: string; + updated_at: string; + version: string; +} + +export interface Comment { + commentId: string; + createdAt: string; + createdBy: ElasticUser; + comment: string; + updatedAt: string; + version: string; +} + export interface CaseSnake { case_id: string; + comments: CommentSnake[]; created_at: string; created_by: ElasticUserSnake; description: string; @@ -23,11 +46,12 @@ export interface CaseSnake { tags: string[]; title: string; updated_at: string; - version?: string; + version: string; } export interface Case { caseId: string; + comments: Comment[]; createdAt: string; createdBy: ElasticUser; description: string; @@ -35,7 +59,7 @@ export interface Case { tags: string[]; title: string; updatedAt: string; - version?: string; + version: string; } export interface QueryParams { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index bf76b69ef22d668..ce71c26078db941 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -52,6 +52,7 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => { const initialData: Case = { caseId: '', createdAt: '', + comments: [], createdBy: { username: '', }, @@ -60,6 +61,7 @@ const initialData: Case = { tags: [], title: '', updatedAt: '', + version: '', }; export const useGetCase = (caseId: string): [CaseState] => { @@ -75,7 +77,7 @@ export const useGetCase = (caseId: string): [CaseState] => { const fetchData = async () => { dispatch({ type: FETCH_INIT }); try { - const response = await getCase(caseId, false); + const response = await getCase(caseId); if (!didCancel) { dispatch({ type: FETCH_SUCCESS, payload: response }); } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx index 5cf99701977d27a..0fcc8a3a1abecb0 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_case.tsx @@ -80,8 +80,7 @@ export const usePostCase = (): [NewCaseState, Dispatch<SetStateAction<NewCase>>] const postCase = async () => { dispatch({ type: FETCH_INIT }); try { - const dataWithoutIsNew = state.data; - delete dataWithoutIsNew.isNew; + const { isNew, ...dataWithoutIsNew } = state.data; const response = await createCase(dataWithoutIsNew); dispatch({ type: FETCH_SUCCESS, payload: response }); } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx new file mode 100644 index 000000000000000..d8abda25af286bb --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_comment.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch, SetStateAction, useEffect, useReducer, useState } from 'react'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, POST_NEW_COMMENT } from './constants'; +import { Comment, NewComment } from './types'; +import { createComment } from './api'; +import { getTypedPayload } from './utils'; + +interface NewCommentState { + data: NewComment; + newComment?: Comment; + isLoading: boolean; + isError: boolean; + caseId: string; +} +interface Action { + type: string; + payload?: NewComment | Comment; +} + +const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoading: true, + isError: false, + }; + case POST_NEW_COMMENT: + return { + ...state, + isLoading: false, + isError: false, + data: getTypedPayload<NewComment>(action.payload), + }; + case FETCH_SUCCESS: + return { + ...state, + isLoading: false, + isError: false, + newComment: getTypedPayload<Comment>(action.payload), + }; + case FETCH_FAILURE: + return { + ...state, + isLoading: false, + isError: true, + }; + default: + throw new Error(); + } +}; +const initialData: NewComment = { + comment: '', +}; + +export const usePostComment = ( + caseId: string +): [NewCommentState, Dispatch<SetStateAction<NewComment>>] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoading: false, + isError: false, + caseId, + data: initialData, + }); + const [formData, setFormData] = useState(initialData); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + dispatch({ type: POST_NEW_COMMENT, payload: formData }); + }, [formData]); + + useEffect(() => { + const postComment = async () => { + dispatch({ type: FETCH_INIT }); + try { + const { isNew, ...dataWithoutIsNew } = state.data; + const response = await createComment(dataWithoutIsNew, state.caseId); + dispatch({ type: FETCH_SUCCESS, payload: response }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); + } + }; + if (state.data.isNew) { + postComment(); + } + }, [state.data.isNew]); + return [state, setFormData]; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 62e3d87b528c09e..ebbb1e14dc2375e 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useReducer } from 'react'; +import { useReducer } from 'react'; import { useStateToaster } from '../../components/toasters'; import { errorToToaster } from '../../components/ml/api/error_to_toaster'; import * as i18n from './translations'; -import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS, UPDATE_CASE_PROPERTY } from './constants'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; import { Case } from './types'; import { updateCaseProperty } from './api'; import { getTypedPayload } from './utils'; @@ -19,7 +19,7 @@ interface NewCaseState { data: Case; isLoading: boolean; isError: boolean; - updateKey?: UpdateKey | null; + updateKey: UpdateKey | null; } interface UpdateByKey { @@ -29,7 +29,7 @@ interface UpdateByKey { interface Action { type: string; - payload?: Partial<Case> | UpdateByKey; + payload?: Partial<Case> | UpdateKey; } const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { @@ -39,20 +39,9 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: true, isError: false, - updateKey: null, - }; - case UPDATE_CASE_PROPERTY: - const { updateKey, updateValue } = getTypedPayload<UpdateByKey>(action.payload); - return { - ...state, - isLoading: false, - isError: false, - data: { - ...state.data, - [updateKey]: updateValue, - }, - updateKey, + updateKey: getTypedPayload<UpdateKey>(action.payload), }; + case FETCH_SUCCESS: return { ...state, @@ -62,12 +51,14 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state.data, ...getTypedPayload<Case>(action.payload), }, + updateKey: null, }; case FETCH_FAILURE: return { ...state, isLoading: false, isError: true, + updateKey: null, }; default: throw new Error(); @@ -77,40 +68,29 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => export const useUpdateCase = ( caseId: string, initialData: Case -): [{ data: Case }, (updates: UpdateByKey) => void] => { +): [NewCaseState, (updates: UpdateByKey) => void] => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, data: initialData, + updateKey: null, }); const [, dispatchToaster] = useStateToaster(); - const dispatchUpdateCaseProperty = ({ updateKey, updateValue }: UpdateByKey) => { - dispatch({ - type: UPDATE_CASE_PROPERTY, - payload: { updateKey, updateValue }, - }); - }; - - useEffect(() => { - const updateData = async (updateKey: keyof Case) => { - dispatch({ type: FETCH_INIT }); - try { - const response = await updateCaseProperty( - caseId, - { [updateKey]: state.data[updateKey] }, - state.data.version ?? '' // saved object versions are typed as string | undefined, hope that's not true - ); - dispatch({ type: FETCH_SUCCESS, payload: response }); - } catch (error) { - errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); - dispatch({ type: FETCH_FAILURE }); - } - }; - if (state.updateKey) { - updateData(state.updateKey); + const dispatchUpdateCaseProperty = async ({ updateKey, updateValue }: UpdateByKey) => { + dispatch({ type: FETCH_INIT, payload: updateKey }); + try { + const response = await updateCaseProperty( + caseId, + { [updateKey]: updateValue }, + state.data.version ?? '' // saved object versions are typed as string | undefined, hope that's not true + ); + dispatch({ type: FETCH_SUCCESS, payload: response }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE }); } - }, [state.updateKey]); + }; - return [{ data: state.data }, dispatchUpdateCaseProperty]; + return [state, dispatchUpdateCaseProperty]; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx new file mode 100644 index 000000000000000..bc8369117433a20 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useReducer, useRef } from 'react'; +import { useStateToaster } from '../../components/toasters'; +import { errorToToaster } from '../../components/ml/api/error_to_toaster'; +import * as i18n from './translations'; +import { FETCH_FAILURE, FETCH_INIT, FETCH_SUCCESS } from './constants'; +import { Comment } from './types'; +import { updateComment } from './api'; +import { getTypedPayload } from './utils'; + +interface CommetUpdateState { + data: Comment[]; + isLoadingIds: string[]; + isError: boolean; +} + +interface CommentUpdate { + update: Partial<Comment>; + commentId: string; +} + +interface Action { + type: string; + payload?: CommentUpdate | string; +} + +const dataFetchReducer = (state: CommetUpdateState, action: Action): CommetUpdateState => { + switch (action.type) { + case FETCH_INIT: + return { + ...state, + isLoadingIds: [...state.isLoadingIds, getTypedPayload<string>(action.payload)], + isError: false, + }; + + case FETCH_SUCCESS: + const updatePayload = getTypedPayload<CommentUpdate>(action.payload); + const foundIndex = state.data.findIndex( + comment => comment.commentId === updatePayload.commentId + ); + state.data[foundIndex] = { ...state.data[foundIndex], ...updatePayload.update }; + return { + ...state, + isLoadingIds: state.isLoadingIds.filter(id => updatePayload.commentId !== id), + isError: false, + data: [...state.data], + }; + case FETCH_FAILURE: + return { + ...state, + isLoadingIds: state.isLoadingIds.filter( + id => getTypedPayload<string>(action.payload) !== id + ), + isError: true, + }; + default: + throw new Error(); + } +}; + +export const useUpdateComment = ( + comments: Comment[] +): [CommetUpdateState, (commentId: string, commentUpdate: string) => void] => { + const [state, dispatch] = useReducer(dataFetchReducer, { + isLoadingIds: [], + isError: false, + data: comments, + }); + const dispatchUpdateComment = useRef<(commentId: string, commentUpdate: string) => void>(); + const [, dispatchToaster] = useStateToaster(); + + dispatchUpdateComment.current = async (commentId: string, commentUpdate: string) => { + dispatch({ type: FETCH_INIT, payload: commentId }); + try { + const currentComment = state.data.find(comment => comment.commentId === commentId) ?? { + version: '', + }; + const response = await updateComment(commentId, commentUpdate, currentComment.version); + dispatch({ type: FETCH_SUCCESS, payload: { update: response, commentId } }); + } catch (error) { + errorToToaster({ title: i18n.ERROR_TITLE, error, dispatchToaster }); + dispatch({ type: FETCH_FAILURE, payload: commentId }); + } + }; + + return [state, dispatchUpdateComment.current]; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx new file mode 100644 index 000000000000000..c8e0dafcf5742b9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback } from 'react'; +import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; +import styled from 'styled-components'; +import { Form, useForm, UseField } from '../../../../shared_imports'; +import { NewComment } from '../../../../containers/case/types'; +import { usePostComment } from '../../../../containers/case/use_post_comment'; +import { schema } from './schema'; +import * as i18n from '../../translations'; +import { MarkdownEditorForm } from '../../../../components/markdown_editor/form'; + +const MySpinner = styled(EuiLoadingSpinner)` + position: absolute; + top: 50%; + left: 50%; +`; + +export const AddComment = React.memo<{ + caseId: string; +}>(({ caseId }) => { + const [{ data, isLoading, newComment }, setFormData] = usePostComment(caseId); + const { form } = useForm({ + defaultValue: data, + options: { stripEmptyFields: false }, + schema, + }); + + const onSubmit = useCallback(async () => { + const { isValid, data: newData } = await form.submit(); + if (isValid && newData.comment) { + setFormData({ ...newData, isNew: true } as NewComment); + } else if (isValid && data.comment) { + setFormData({ ...data, ...newData, isNew: true } as NewComment); + } + }, [form, data]); + + return ( + <> + {isLoading && <MySpinner size="xl" />} + <Form form={form}> + <UseField + path="comment" + component={MarkdownEditorForm} + componentProps={{ + idAria: 'caseComment', + isDisabled: isLoading, + dataTestSubj: 'caseComment', + placeholder: i18n.ADD_COMMENT_HELP_TEXT, + footerContentRight: ( + <EuiButton + iconType="plusInCircle" + isDisabled={isLoading} + isLoading={isLoading} + onClick={onSubmit} + size="s" + > + {i18n.ADD_COMMENT} + </EuiButton> + ), + }} + /> + </Form> + {newComment && + 'TO DO new comment got added but we didnt update the UI yet. Refresh the page to see your comment ;)'} + </> + ); +}); + +AddComment.displayName = 'AddComment'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx new file mode 100644 index 000000000000000..5f30f59149d99b4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/schema.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports'; +import * as i18n from '../../translations'; + +const { emptyField } = fieldValidators; + +export const schema: FormSchema = { + comment: { + type: FIELD_TYPES.TEXTAREA, + validations: [ + { + validator: emptyField(i18n.COMMENT_REQUIRED), + }, + ], + }, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 98a67304fcf1f1d..0169493773b748f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -14,51 +14,61 @@ export const useGetCasesMockState: UseGetCasesState = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'open', tags: ['defacement'], title: 'Another horrible breach', updatedAt: '2020-02-13T19:44:23.627Z', + version: 'WzQ3LDFd', }, { caseId: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'open', tags: ['phishing'], title: 'Bad email', updatedAt: '2020-02-13T19:44:13.328Z', + version: 'WzQ3LDFd', }, { caseId: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'open', tags: ['phishing'], title: 'Bad email', updatedAt: '2020-02-13T19:44:11.328Z', + version: 'WzQ3LDFd', }, { caseId: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'closed', tags: ['phishing'], title: 'Uh oh', updatedAt: '2020-02-18T21:32:24.056Z', + version: 'WzQ3LDFd', }, { caseId: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, + comments: [], description: 'Security banana Issue', state: 'open', tags: ['phishing'], title: 'Uh oh', updatedAt: '2020-02-13T19:44:01.901Z', + version: 'WzQ3LDFd', }, ], page: 1, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 4c47bf605051d5b..9c276d1b24da1b3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -19,7 +19,7 @@ const renderStringField = (field: string, dataTestSubj: string) => export const getCasesColumns = (): CasesColumns[] => [ { - name: i18n.CASE_TITLE, + name: i18n.NAME, render: (theCase: Case) => { if (theCase.caseId != null && theCase.title != null) { return <CaseDetailsLink detailName={theCase.caseId}>{theCase.title}</CaseDetailsLink>; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 7480c4fc4bb2a0e..89d321c6d106a5c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -11,6 +11,19 @@ export const caseProps: CaseProps = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', initialData: { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + comments: [ + { + comment: 'Solve this fast!', + commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + createdAt: '2020-02-20T23:06:33.798Z', + createdBy: { + fullName: 'Steph Milovic', + username: 'smilovic', + }, + updatedAt: '2020-02-20T23:06:33.798Z', + version: 'WzQ3LDFd', + }, + ], createdAt: '2020-02-13T19:44:23.627Z', createdBy: { fullName: null, username: 'elastic' }, description: 'Security banana Issue', @@ -18,12 +31,25 @@ export const caseProps: CaseProps = { tags: ['defacement'], title: 'Another horrible breach!!', updatedAt: '2020-02-19T15:02:57.995Z', + version: 'WzQ3LDFd', }, - isLoading: false, }; export const data: Case = { caseId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + comments: [ + { + comment: 'Solve this fast!', + commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + createdAt: '2020-02-20T23:06:33.798Z', + createdBy: { + fullName: 'Steph Milovic', + username: 'smilovic', + }, + updatedAt: '2020-02-20T23:06:33.798Z', + version: 'WzQ3LDFd', + }, + ], createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic', fullName: null }, description: 'Security banana Issue', @@ -31,4 +57,5 @@ export const data: Case = { tags: ['defacement'], title: 'Another horrible breach!!', updatedAt: '2020-02-19T15:02:57.995Z', + version: 'WzQ3LDFd', }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index a9e694bad705dac..1539b3de5a0c134 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -16,7 +16,12 @@ describe('CaseView ', () => { beforeEach(() => { jest.resetAllMocks(); - jest.spyOn(apiHook, 'useUpdateCase').mockReturnValue([{ data }, dispatchUpdateCaseProperty]); + jest + .spyOn(apiHook, 'useUpdateCase') + .mockReturnValue([ + { data, isLoading: false, isError: false, updateKey: null }, + dispatchUpdateCaseProperty, + ]); }); it('should render CaseComponent', () => { @@ -79,4 +84,38 @@ describe('CaseView ', () => { updateValue: 'closed', }); }); + + it('should render comments', () => { + const wrapper = mount( + <TestProviders> + <CaseComponent {...caseProps} /> + </TestProviders> + ); + expect( + wrapper + .find( + `div[data-test-subj="user-action-${data.comments[0].commentId}-avatar"] [data-test-subj="user-action-avatar"]` + ) + .first() + .prop('name') + ).toEqual(data.comments[0].createdBy.fullName); + + expect( + wrapper + .find( + `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="user-action-title"] strong` + ) + .first() + .text() + ).toEqual(data.comments[0].createdBy.username); + + expect( + wrapper + .find( + `div[data-test-subj="user-action-${data.comments[0].commentId}"] [data-test-subj="markdown"]` + ) + .first() + .prop('source') + ).toEqual(data.comments[0].comment); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index df3e30a698b56e9..605f9e8fa17134b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import { EuiBadge, - EuiButton, - EuiButtonEmpty, EuiButtonToggle, EuiDescriptionList, EuiDescriptionListDescription, @@ -20,13 +18,11 @@ import { import styled, { css } from 'styled-components'; import * as i18n from './translations'; -import { DescriptionMarkdown } from '../description_md_editor'; import { Case } from '../../../../containers/case/types'; import { FormattedRelativePreferenceDate } from '../../../../components/formatted_date'; import { getCaseUrl } from '../../../../components/link_to'; import { HeaderPage } from '../../../../components/header_page'; import { EditableTitle } from '../../../../components/header_page/editable_title'; -import { Markdown } from '../../../../components/markdown'; import { PropertyActions } from '../property_actions'; import { TagList } from '../tag_list'; import { useGetCase } from '../../../../containers/case/use_get_case'; @@ -34,6 +30,7 @@ import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; import { WrapperPage } from '../../../../components/wrapper_page'; +import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; interface Props { @@ -53,95 +50,71 @@ const MyWrapper = styled(WrapperPage)` padding-bottom: 0; `; +const MyEuiFlexGroup = styled(EuiFlexGroup)` + height: 100%; +`; + export interface CaseProps { caseId: string; initialData: Case; - isLoading: boolean; } -export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoading }) => { - const [{ data }, dispatchUpdateCaseProperty] = useUpdateCase(caseId, initialData); - const [isEditDescription, setIsEditDescription] = useState(false); - const [isEditTags, setIsEditTags] = useState(false); - const [isCaseOpen, setIsCaseOpen] = useState(data.state === 'open'); - const [description, setDescription] = useState(data.description); - const [title, setTitle] = useState(data.title); - const [tags, setTags] = useState(data.tags); +export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData }) => { + const [{ data, isLoading, updateKey }, dispatchUpdateCaseProperty] = useUpdateCase( + caseId, + initialData + ); const onUpdateField = useCallback( - async (updateKey: keyof Case, updateValue: string | string[]) => { - switch (updateKey) { + (newUpdateKey: keyof Case, updateValue: Case[keyof Case]) => { + switch (newUpdateKey) { case 'title': - if (updateValue.length > 0) { + const titleUpdate = getTypedPayload<string>(updateValue); + if (titleUpdate.length > 0) { dispatchUpdateCaseProperty({ updateKey: 'title', - updateValue, + updateValue: titleUpdate, }); } break; case 'description': - if (updateValue.length > 0) { + const descriptionUpdate = getTypedPayload<string>(updateValue); + if (descriptionUpdate.length > 0) { dispatchUpdateCaseProperty({ updateKey: 'description', - updateValue, + updateValue: descriptionUpdate, }); - setIsEditDescription(false); } break; case 'tags': - setTags(updateValue as string[]); - if (updateValue.length > 0) { + const tagsUpdate = getTypedPayload<string[]>(updateValue); + dispatchUpdateCaseProperty({ + updateKey: 'tags', + updateValue: tagsUpdate, + }); + break; + case 'state': + const stateUpdate = getTypedPayload<string>(updateValue); + if (data.state !== updateValue) { dispatchUpdateCaseProperty({ - updateKey: 'tags', - updateValue, + updateKey: 'state', + updateValue: stateUpdate, }); - setIsEditTags(false); } - break; default: return null; } }, - [dispatchUpdateCaseProperty, title] + [dispatchUpdateCaseProperty, data.state] ); - const onSetIsCaseOpen = useCallback(() => setIsCaseOpen(!isCaseOpen), [ - isCaseOpen, - setIsCaseOpen, - ]); - - useEffect(() => { - const caseState = isCaseOpen ? 'open' : 'closed'; - if (data.state !== caseState) { - dispatchUpdateCaseProperty({ - updateKey: 'state', - updateValue: caseState, - }); - } - }, [isCaseOpen]); - // TO DO refactor each of these const's into their own components const propertyActions = [ - { - iconType: 'documentEdit', - label: 'Edit description', - onClick: () => setIsEditDescription(true), - }, - { - iconType: 'securitySignalResolved', - label: 'Close case', - onClick: () => null, - }, { iconType: 'trash', label: 'Delete case', onClick: () => null, }, - { - iconType: 'importAction', - label: 'Push as ServiceNow incident', - onClick: () => null, - }, { iconType: 'popout', label: 'View ServiceNow incident', @@ -153,66 +126,13 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa onClick: () => null, }, ]; - const userActions = [ - { - avatarName: data.createdBy.username, - title: ( - <EuiFlexGroup alignItems="baseline" gutterSize="none" justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <p> - <strong>{`${data.createdBy.username}`}</strong> - {` ${i18n.ADDED_DESCRIPTION} `}{' '} - <FormattedRelativePreferenceDate value={data.createdAt} /> - {/* STEPH FIX come back and add label `on` */} - </p> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <PropertyActions propertyActions={propertyActions} /> - </EuiFlexItem> - </EuiFlexGroup> - ), - children: isEditDescription ? ( - <> - <DescriptionMarkdown - descriptionInputHeight={200} - initialDescription={data.description} - isLoading={isLoading} - onChange={updatedDescription => setDescription(updatedDescription)} - /> - <EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}> - <EuiFlexItem grow={false}> - <EuiButton - fill - isDisabled={isLoading} - isLoading={isLoading} - onClick={() => onUpdateField('description', description)} - > - {i18n.SUBMIT} - </EuiButton> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonEmpty onClick={() => setIsEditDescription(false)}> - {i18n.CANCEL} - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - </> - ) : ( - <Markdown raw={data.description} data-test-subj="case-view-description" /> - ), - }, - ]; - - const onSubmit = useCallback( - newTitle => { - onUpdateField('title', newTitle); - setTitle(newTitle); - }, - [title] + const onSubmit = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); + const toggleStateCase = useCallback( + e => onUpdateField('state', e.target.checked ? 'open' : 'closed'), + [onUpdateField] ); - - const titleNode = <EditableTitle isLoading={isLoading} title={title} onSubmit={onSubmit} />; + const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); return ( <> @@ -223,8 +143,14 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa text: i18n.BACK_TO_ALL, }} data-test-subj="case-view-title" - titleNode={titleNode} - title={title} + titleNode={ + <EditableTitle + isLoading={isLoading && updateKey === 'title'} + title={data.title} + onSubmit={onSubmit} + /> + } + title={data.title} > <EuiFlexGroup gutterSize="l" justifyContent="flexEnd"> <EuiFlexItem grow={false}> @@ -234,7 +160,7 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa <EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle> <EuiDescriptionListDescription> <EuiBadge - color={isCaseOpen ? 'secondary' : 'danger'} + color={data.state === 'open' ? 'secondary' : 'danger'} data-test-subj="case-view-state" > {data.state} @@ -258,10 +184,11 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa <EuiFlexItem> <EuiButtonToggle data-test-subj="toggle-case-state" - label={isCaseOpen ? 'Close case' : 'Reopen case'} - iconType={isCaseOpen ? 'checkInCircleFilled' : 'magnet'} - onChange={onSetIsCaseOpen} - isSelected={isCaseOpen} + iconType={data.state === 'open' ? 'checkInCircleFilled' : 'magnet'} + isLoading={isLoading && updateKey === 'state'} + isSelected={data.state === 'open'} + label={data.state === 'open' ? 'Close case' : 'Reopen case'} + onChange={toggleStateCase} /> </EuiFlexItem> <EuiFlexItem grow={false}> @@ -276,7 +203,11 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa <MyWrapper> <EuiFlexGroup> <EuiFlexItem grow={6}> - <UserActionTree userActions={userActions} /> + <UserActionTree + data={data} + isLoadingDescription={isLoading && updateKey === 'description'} + onUpdateField={onUpdateField} + /> </EuiFlexItem> <EuiFlexItem grow={2}> <UserList @@ -286,14 +217,9 @@ export const CaseComponent = React.memo<CaseProps>(({ caseId, initialData, isLoa /> <TagList data-test-subj="case-view-tag-list" - tags={tags} - iconAction={{ - 'aria-label': title, - iconType: 'pencil', - onSubmit: newTags => onUpdateField('tags', newTags), - onClick: isEdit => setIsEditTags(isEdit), - }} - isEditTags={isEditTags} + tags={data.tags} + onSubmit={onSubmitTags} + isLoading={isLoading && updateKey === 'tags'} /> </EuiFlexItem> </EuiFlexGroup> @@ -310,15 +236,15 @@ export const CaseView = React.memo(({ caseId }: Props) => { } if (isLoading) { return ( - <EuiFlexGroup justifyContent="center" alignItems="center"> + <MyEuiFlexGroup justifyContent="center" alignItems="center"> <EuiFlexItem grow={false}> <EuiLoadingSpinner size="xl" /> </EuiFlexItem> - </EuiFlexGroup> + </MyEuiFlexGroup> ); } - return <CaseComponent caseId={caseId} initialData={data} isLoading={isLoading} />; + return <CaseComponent caseId={caseId} initialData={data} />; }); CaseView.displayName = 'CaseView'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index f45c52533d2e7b8..82b5e771e21513e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -32,6 +32,18 @@ export const EDITED_DESCRIPTION = i18n.translate( } ); +export const EDIT_DESCRIPTION = i18n.translate('xpack.siem.case.caseView.edit.description', { + defaultMessage: 'Edit description', +}); + +export const EDIT_COMMENT = i18n.translate('xpack.siem.case.caseView.edit.comment', { + defaultMessage: 'Edit comment', +}); + +export const ON = i18n.translate('xpack.siem.case.caseView.actionLabel.on', { + defaultMessage: 'on', +}); + export const ADDED_COMMENT = i18n.translate('xpack.siem.case.caseView.actionLabel.addComment', { defaultMessage: 'added comment', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx index 7d79e287b22e709..65d7256fd6e20c6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/index.tsx @@ -3,38 +3,48 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiButton, + EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, - EuiHorizontalRule, EuiLoadingSpinner, EuiPanel, } from '@elastic/eui'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { Redirect } from 'react-router-dom'; -import { Field, Form, getUseField, useForm } from '../../../shared_imports'; +import { Field, Form, getUseField, useForm, UseField } from '../../../../shared_imports'; import { NewCase } from '../../../../containers/case/types'; import { usePostCase } from '../../../../containers/case/use_post_case'; import { schema } from './schema'; import * as i18n from '../../translations'; import { SiemPageName } from '../../../home/types'; -import { DescriptionMarkdown } from '../description_md_editor'; +import { MarkdownEditorForm } from '../../../../components/markdown_editor/form'; export const CommonUseField = getUseField({ component: Field }); -const TagContainer = styled.div` - margin-top: 16px; +const ContainerBig = styled.div` + ${({ theme }) => css` + margin-top: ${theme.eui.euiSizeXL}; + `} +`; + +const Container = styled.div` + ${({ theme }) => css` + margin-top: ${theme.eui.euiSize}; + `} `; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; top: 50%; left: 50%; + z-index: 99; `; export const Create = React.memo(() => { const [{ data, isLoading, newCase }, setFormData] = usePostCase(); + const [isCancel, setIsCancel] = useState(false); const { form } = useForm({ defaultValue: data, options: { stripEmptyFields: false }, @@ -43,14 +53,19 @@ export const Create = React.memo(() => { const onSubmit = useCallback(async () => { const { isValid, data: newData } = await form.submit(); - if (isValid) { + if (isValid && newData.description) { setFormData({ ...newData, isNew: true } as NewCase); + } else if (isValid && data.description) { + setFormData({ ...data, ...newData, isNew: true } as NewCase); } - }, [form]); + }, [form, data]); if (newCase && newCase.caseId) { return <Redirect to={`/${SiemPageName.case}/${newCase.caseId}`} />; } + if (isCancel) { + return <Redirect to={`/${SiemPageName.case}`} />; + } return ( <EuiPanel> {isLoading && <MySpinner size="xl" />} @@ -62,18 +77,11 @@ export const Create = React.memo(() => { 'data-test-subj': 'caseTitle', euiFieldProps: { fullWidth: false, + disabled: isLoading, }, - isDisabled: isLoading, }} /> - <DescriptionMarkdown - descriptionInputHeight={200} - formHook={true} - initialDescription={data.description} - isLoading={isLoading} - onChange={description => setFormData({ ...data, description })} - /> - <TagContainer> + <Container> <CommonUseField path="tags" componentProps={{ @@ -82,14 +90,24 @@ export const Create = React.memo(() => { euiFieldProps: { fullWidth: true, placeholder: '', + isDisabled: isLoading, }, + }} + /> + </Container> + <ContainerBig> + <UseField + path="description" + component={MarkdownEditorForm} + componentProps={{ + idAria: 'caseDescription', + dataTestSubj: 'caseDescription', isDisabled: isLoading, }} /> - </TagContainer> + </ContainerBig> </Form> - <> - <EuiHorizontalRule margin="m" /> + <Container> <EuiFlexGroup alignItems="center" justifyContent="flexEnd" @@ -97,12 +115,23 @@ export const Create = React.memo(() => { responsive={false} > <EuiFlexItem grow={false}> - <EuiButton fill isDisabled={isLoading} isLoading={isLoading} onClick={onSubmit}> - {i18n.SUBMIT} + <EuiButtonEmpty size="s" onClick={() => setIsCancel(true)} iconType="cross"> + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + fill + iconType="plusInCircle" + isDisabled={isLoading} + isLoading={isLoading} + onClick={onSubmit} + > + {i18n.CREATE_CASE} </EuiButton> </EuiFlexItem> </EuiFlexGroup> - </> + </Container> </EuiPanel> ); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx index 1b5df72a6671ca8..c81a31f0d4f3f2d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/create/schema.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; +import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../../shared_imports'; import { OptionalFieldLabel } from './optional_field_label'; import * as i18n from '../../translations'; @@ -13,7 +13,7 @@ const { emptyField } = fieldValidators; export const schema: FormSchema = { title: { type: FIELD_TYPES.TEXT, - label: i18n.CASE_TITLE, + label: i18n.NAME, validations: [ { validator: emptyField(i18n.TITLE_REQUIRED), @@ -21,7 +21,7 @@ export const schema: FormSchema = { ], }, description: { - type: FIELD_TYPES.TEXTAREA, + label: i18n.DESCRIPTION, validations: [ { validator: emptyField(i18n.DESCRIPTION_REQUIRED), diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx deleted file mode 100644 index 44062a5a1d5897b..000000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/description_md_editor/index.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFlexItem, EuiPanel, EuiTabbedContent, EuiTextArea } from '@elastic/eui'; -import React, { useState } from 'react'; -import styled from 'styled-components'; - -import { Markdown } from '../../../../components/markdown'; -import * as i18n from '../../translations'; -import { MarkdownHint } from '../../../../components/markdown/markdown_hint'; -import { CommonUseField } from '../create'; - -const TextArea = styled(EuiTextArea)<{ height: number }>` - min-height: ${({ height }) => `${height}px`}; - width: 100%; -`; - -TextArea.displayName = 'TextArea'; - -const DescriptionContainer = styled.div` - margin-top: 15px; - margin-bottom: 15px; -`; - -const DescriptionMarkdownTabs = styled(EuiTabbedContent)` - width: 100%; -`; - -DescriptionMarkdownTabs.displayName = 'DescriptionMarkdownTabs'; - -const MarkdownContainer = styled(EuiPanel)<{ height: number }>` - height: ${({ height }) => height}px; - overflow: auto; -`; - -MarkdownContainer.displayName = 'MarkdownContainer'; - -/** An input for entering a new case description */ -export const DescriptionMarkdown = React.memo<{ - descriptionInputHeight: number; - initialDescription: string; - isLoading: boolean; - formHook?: boolean; - onChange: (description: string) => void; -}>(({ initialDescription, isLoading, descriptionInputHeight, onChange, formHook = false }) => { - const [description, setDescription] = useState(initialDescription); - const tabs = [ - { - id: 'description', - name: i18n.DESCRIPTION, - content: formHook ? ( - <CommonUseField - path="description" - onChange={e => { - setDescription(e as string); - onChange(e as string); - }} - componentProps={{ - idAria: 'caseDescription', - 'data-test-subj': 'caseDescription', - isDisabled: isLoading, - spellcheck: false, - }} - /> - ) : ( - <TextArea - onChange={e => { - setDescription(e.target.value); - onChange(e.target.value); - }} - fullWidth={true} - height={descriptionInputHeight} - aria-label={i18n.DESCRIPTION} - disabled={isLoading} - spellCheck={false} - value={description} - /> - ), - }, - { - id: 'preview', - name: i18n.PREVIEW, - content: ( - <MarkdownContainer - data-test-subj="markdown-container" - height={descriptionInputHeight} - paddingSize="s" - > - <Markdown raw={description} /> - </MarkdownContainer> - ), - }, - ]; - return ( - <DescriptionContainer> - <DescriptionMarkdownTabs - data-test-subj="new-description-tabs" - tabs={tabs} - initialSelectedTab={tabs[0]} - /> - <EuiFlexItem grow={true}> - <MarkdownHint show={description.trim().length > 0} /> - </EuiFlexItem> - </DescriptionContainer> - ); -}); - -DescriptionMarkdown.displayName = 'DescriptionMarkdown'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx index 6634672cb6a7755..3513d4de12aa1dc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiText, EuiHorizontalRule, @@ -14,24 +14,18 @@ import { EuiButton, EuiButtonEmpty, EuiButtonIcon, + EuiLoadingSpinner, } from '@elastic/eui'; import styled, { css } from 'styled-components'; import * as i18n from '../../translations'; -import { Form, useForm } from '../../../shared_imports'; +import { Form, useForm } from '../../../../shared_imports'; import { schema } from './schema'; import { CommonUseField } from '../create'; -interface IconAction { - 'aria-label': string; - iconType: string; - onClick: (b: boolean) => void; - onSubmit: (a: string[]) => void; -} - interface TagListProps { + isLoading: boolean; + onSubmit: (a: string[]) => void; tags: string[]; - iconAction?: IconAction; - isEditTags?: boolean; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -43,37 +37,35 @@ const MyFlexGroup = styled(EuiFlexGroup)` `} `; -export const TagList = React.memo(({ tags, isEditTags, iconAction }: TagListProps) => { +export const TagList = React.memo(({ isLoading, onSubmit, tags }: TagListProps) => { const { form } = useForm({ defaultValue: { tags }, options: { stripEmptyFields: false }, schema, }); + const [isEditTags, setIsEditTags] = useState(false); - const onSubmit = useCallback(async () => { + const onSubmitTags = useCallback(async () => { const { isValid, data: newData } = await form.submit(); - if (isValid && iconAction) { - iconAction.onSubmit(newData.tags); - iconAction.onClick(false); + if (isValid && newData.tags) { + onSubmit(newData.tags); + setIsEditTags(false); } - }, [form]); + }, [form, onSubmit]); - const onActionClick = useCallback( - (cb: (b: boolean) => void, onClickBool: boolean) => cb(onClickBool), - [iconAction] - ); return ( <EuiText> <EuiFlexGroup alignItems="center" gutterSize="xs" justifyContent="spaceBetween"> <EuiFlexItem grow={false}> <h4>{i18n.TAGS}</h4> </EuiFlexItem> - {iconAction && ( + {isLoading && <EuiLoadingSpinner />} + {!isLoading && ( <EuiFlexItem grow={false}> <EuiButtonIcon - aria-label={iconAction['aria-label']} - iconType={iconAction.iconType} - onClick={() => onActionClick(iconAction.onClick, true)} + aria-label={'tags'} + iconType={'pencil'} + onClick={setIsEditTags.bind(null, true)} /> </EuiFlexItem> )} @@ -88,7 +80,7 @@ export const TagList = React.memo(({ tags, isEditTags, iconAction }: TagListProp <EuiBadge color="hollow">{tag}</EuiBadge> </EuiFlexItem> ))} - {isEditTags && iconAction && ( + {isEditTags && ( <EuiFlexGroup direction="column"> <EuiFlexItem> <Form form={form}> @@ -106,14 +98,22 @@ export const TagList = React.memo(({ tags, isEditTags, iconAction }: TagListProp </Form> </EuiFlexItem> <EuiFlexItem> - <EuiButton fill onClick={onSubmit}> - {i18n.SUBMIT} - </EuiButton> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonEmpty onClick={() => onActionClick(iconAction.onClick, false)}> - {i18n.CANCEL} - </EuiButtonEmpty> + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiButton color="secondary" fill iconType="save" onClick={onSubmitTags} size="s"> + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + iconType="cross" + onClick={setIsEditTags.bind(null, false)} + size="s" + > + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> )} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx index dfc9c61cd5f0c7c..26a89408069fbc6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/tag_list/schema.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { FormSchema } from '../../../shared_imports'; +import { FormSchema } from '../../../../shared_imports'; import { schema as createSchema } from '../create/schema'; export const schema: FormSchema = { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 8df98a4cef0e8ad..6599151f9d4fd75 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -4,18 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { ReactNode } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiAvatar, EuiPanel, EuiText } from '@elastic/eui'; +import React, { ReactNode, useCallback, useMemo, useState } from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; import styled, { css } from 'styled-components'; +import * as i18n from '../case_view/translations'; + +import { Case } from '../../../../containers/case/types'; +import { useUpdateComment } from '../../../../containers/case/use_update_comment'; +import { UserActionItem } from './user_action_item'; +import { UserActionMarkdown } from './user_action_markdown'; +import { AddComment } from '../add_comment'; export interface UserActionItem { avatarName: string; children?: ReactNode; - title: ReactNode; + skipPanel?: boolean; + title?: ReactNode; } export interface UserActionTreeProps { - userActions: UserActionItem[]; + data: Case; + isLoadingDescription: boolean; + onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; } const UserAction = styled(EuiFlexGroup)` @@ -48,35 +58,110 @@ const UserAction = styled(EuiFlexGroup)` border-bottom: ${theme.eui.euiBorderThin}; border-radius: ${theme.eui.euiBorderRadius} ${theme.eui.euiBorderRadius} 0 0; } - .userAction__content { - padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeL}; - } .euiText--small * { margin-bottom: 0; } `} `; -const renderUserActions = (userActions: UserActionItem[]) => { - return userActions.map(({ avatarName, children, title }, key) => ( - <UserAction key={key} gutterSize={'none'}> - <EuiFlexItem grow={false}> - <EuiAvatar className="userAction__circle" name={avatarName} /> - </EuiFlexItem> - <EuiFlexItem> - <EuiPanel className="userAction__panel" paddingSize="none"> - <EuiText size="s" className="userAction__title"> - {title} - </EuiText> - {children && <div className="userAction__content">{children}</div>} - </EuiPanel> - </EuiFlexItem> - </UserAction> - )); -}; +const DescriptionId = 'description'; +const NewId = 'newComent'; + +export const UserActionTree = React.memo( + ({ data, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { + const [{ data: comments, isLoadingIds }, dispatchUpdateComment] = useUpdateComment( + data.comments + ); + + const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]); + + const handleManageMarkdownEditId = useCallback( + (id: string) => { + if (!manageMarkdownEditIds.includes(id)) { + setManangeMardownEditIds([...manageMarkdownEditIds, id]); + } else { + setManangeMardownEditIds(manageMarkdownEditIds.filter(myId => id !== myId)); + } + }, + [manageMarkdownEditIds] + ); + + const handleSaveComment = useCallback( + (id: string, content: string) => { + handleManageMarkdownEditId(id); + dispatchUpdateComment(id, content); + }, + [handleManageMarkdownEditId, dispatchUpdateComment] + ); + + const MarkdownDescription = useMemo( + () => ( + <UserActionMarkdown + id={DescriptionId} + content={data.description} + isEditable={manageMarkdownEditIds.includes(DescriptionId)} + onSaveContent={(content: string) => { + handleManageMarkdownEditId(DescriptionId); + onUpdateField(DescriptionId, content); + }} + onChangeEditable={handleManageMarkdownEditId} + /> + ), + [data.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField] + ); + + const MarkdownNewComment = useMemo(() => <AddComment caseId={data.caseId} />, [data.caseId]); -export const UserActionTree = React.memo(({ userActions }: UserActionTreeProps) => ( - <div>{renderUserActions(userActions)}</div> -)); + return ( + <UserAction data-test-subj="user-action-description" gutterSize={'none'}> + <UserActionItem + createdAt={data.createdAt} + id={DescriptionId} + isEditable={manageMarkdownEditIds.includes(DescriptionId)} + isLoading={isLoadingDescription} + labelAction={i18n.EDIT_DESCRIPTION} + labelTitle={i18n.ADDED_DESCRIPTION} + fullName={data.createdBy.fullName ?? data.createdBy.username} + markdown={MarkdownDescription} + onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)} + userName={data.createdBy.username} + /> + {comments.map(comment => ( + <UserActionItem + key={comment.commentId} + createdAt={comment.createdAt} + id={comment.commentId} + isEditable={manageMarkdownEditIds.includes(comment.commentId)} + isLoading={isLoadingIds.includes(comment.commentId)} + labelAction={i18n.EDIT_COMMENT} + labelTitle={i18n.ADDED_COMMENT} + fullName={comment.createdBy.fullName ?? comment.createdBy.username} + markdown={ + <UserActionMarkdown + id={comment.commentId} + content={comment.comment} + isEditable={manageMarkdownEditIds.includes(comment.commentId)} + onChangeEditable={handleManageMarkdownEditId} + onSaveContent={handleSaveComment.bind(null, comment.commentId)} + /> + } + onEdit={handleManageMarkdownEditId.bind(null, comment.commentId)} + userName={comment.createdBy.username} + /> + ))} + <UserActionItem + createdAt={new Date().toISOString()} + id={NewId} + isEditable={true} + isLoading={isLoadingIds.includes(NewId)} + fullName="to be determined" + markdown={MarkdownNewComment} + onEdit={handleManageMarkdownEditId.bind(null, NewId)} + userName="to be determined" + /> + </UserAction> + ); + } +); UserActionTree.displayName = 'UserActionTree'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_avatar.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_avatar.tsx new file mode 100644 index 000000000000000..f3276bd50e72c9f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_avatar.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiAvatar } from '@elastic/eui'; +import React from 'react'; + +interface UserActionAvatarProps { + name: string; +} + +export const UserActionAvatar = ({ name }: UserActionAvatarProps) => { + return ( + <EuiAvatar data-test-subj={`user-action-avatar`} className="userAction__circle" name={name} /> + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx new file mode 100644 index 000000000000000..816e50082759086 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexItem, EuiPanel } from '@elastic/eui'; +import React from 'react'; + +import { UserActionAvatar } from './user_action_avatar'; +import { UserActionTitle } from './user_action_title'; + +interface UserActionItemProps { + createdAt: string; + id: string; + isEditable: boolean; + isLoading: boolean; + labelAction?: string; + labelTitle?: string; + fullName: string; + markdown: React.ReactNode; + onEdit: (id: string) => void; + userName: string; +} + +export const UserActionItem = ({ + createdAt, + id, + isEditable, + isLoading, + labelAction, + labelTitle, + fullName, + markdown, + onEdit, + userName, +}: UserActionItemProps) => ( + <> + <EuiFlexItem data-test-subj={`user-action-${id}-avatar`} grow={false}> + <UserActionAvatar name={fullName ?? userName} /> + </EuiFlexItem> + <EuiFlexItem data-test-subj={`user-action-${id}`}> + {isEditable && markdown} + {!isEditable && ( + <EuiPanel className="userAction__panel" paddingSize="none"> + <UserActionTitle + createdAt={createdAt} + id={id} + isLoading={isLoading} + labelAction={labelAction ?? ''} + labelTitle={labelTitle ?? ''} + userName={userName} + onEdit={onEdit} + /> + {markdown} + </EuiPanel> + )} + </EuiFlexItem> + </> +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx new file mode 100644 index 000000000000000..6a50bf24e9d7e1e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_markdown.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import styled, { css } from 'styled-components'; + +import { MarkdownEditor } from '../../../../components/markdown_editor'; +import * as i18n from '../case_view/translations'; +import { Markdown } from '../../../../components/markdown'; + +const ContentWrapper = styled.div` + ${({ theme }) => css` + padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeL}; + `} +`; + +interface UserActionMarkdownProps { + content: string; + id: string; + isEditable: boolean; + onChangeEditable: (id: string) => void; + onSaveContent: (content: string) => void; +} + +export const UserActionMarkdown = ({ + id, + content, + isEditable, + onChangeEditable, + onSaveContent, +}: UserActionMarkdownProps) => { + const [myContent, setMyContent] = useState(content); + + const handleCancelAction = useCallback(() => { + onChangeEditable(id); + }, [id, onChangeEditable]); + + const handleSaveAction = useCallback(() => { + if (myContent !== content) { + onSaveContent(content); + } + onChangeEditable(id); + }, [content, id, myContent, onChangeEditable, onSaveContent]); + + const handleOnChange = useCallback(() => { + if (myContent !== content) { + setMyContent(content); + } + }, [content, myContent]); + + const renderButtons = useCallback( + ({ cancelAction, saveAction }) => { + return ( + <EuiFlexGroup gutterSize="s" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty size="s" onClick={cancelAction} iconType="cross"> + {i18n.CANCEL} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton color="secondary" fill iconType="save" onClick={saveAction} size="s"> + {i18n.SAVE} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + ); + }, + [handleCancelAction, handleSaveAction] + ); + + return isEditable ? ( + <MarkdownEditor + footerContentRight={renderButtons({ + cancelAction: handleCancelAction, + saveAction: handleSaveAction, + })} + initialContent={content} + onChange={handleOnChange} + /> + ) : ( + <ContentWrapper> + <Markdown raw={content} data-test-subj="case-view-description" /> + </ContentWrapper> + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx new file mode 100644 index 000000000000000..6ad60fb9f963ee9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +import { + FormattedRelativePreferenceDate, + FormattedRelativePreferenceLabel, +} from '../../../../components/formatted_date'; +import * as i18n from '../case_view/translations'; +import { PropertyActions } from '../property_actions'; + +const MySpinner = styled(EuiLoadingSpinner)` + .euiLoadingSpinner { + margin-top: 1px; // yes it matters! + } +`; + +interface UserActionTitleProps { + createdAt: string; + id: string; + isLoading: boolean; + labelAction: string; + labelTitle: string; + userName: string; + onEdit: (id: string) => void; +} + +export const UserActionTitle = ({ + createdAt, + id, + isLoading, + labelAction, + labelTitle, + userName, + onEdit, +}: UserActionTitleProps) => { + const propertyActions = useMemo(() => { + return [ + { + iconType: 'documentEdit', + label: labelAction, + onClick: () => onEdit(id), + }, + ]; + }, [id, onEdit]); + return ( + <EuiText size="s" className="userAction__title" data-test-subj={`user-action-title`}> + <EuiFlexGroup alignItems="baseline" gutterSize="none" justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <p> + <strong>{userName}</strong> + {` ${labelTitle} `} + <FormattedRelativePreferenceLabel value={createdAt} preferenceLabel={`${i18n.ON} `} /> + <FormattedRelativePreferenceDate value={createdAt} /> + </p> + </EuiFlexItem> + <EuiFlexItem grow={false}> + {isLoading && <MySpinner />} + {!isLoading && <PropertyActions propertyActions={propertyActions} />} + </EuiFlexItem> + </EuiFlexGroup> + </EuiText> + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx index 33e0a9541c5b45d..abb49122dc1421f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_list/index.tsx @@ -32,12 +32,12 @@ const MyFlexGroup = styled(EuiFlexGroup)` `; const renderUsers = (users: ElasticUser[]) => { - return users.map(({ username }, key) => ( + return users.map(({ fullName, username }, key) => ( <MyFlexGroup key={key} justifyContent="spaceBetween"> <EuiFlexItem grow={false}> <EuiFlexGroup gutterSize="xs"> <EuiFlexItem> - <MyAvatar name={username} /> + <MyAvatar name={fullName ? fullName : username} /> </EuiFlexItem> <EuiFlexItem> <p> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 265af0bde547f29..5f0509586fc8147 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -14,8 +14,8 @@ export const CANCEL = i18n.translate('xpack.siem.case.caseView.cancel', { defaultMessage: 'Cancel', }); -export const CASE_TITLE = i18n.translate('xpack.siem.case.caseView.caseTitle', { - defaultMessage: 'Case Title', +export const NAME = i18n.translate('xpack.siem.case.caseView.name', { + defaultMessage: 'Name', }); export const CREATED_AT = i18n.translate('xpack.siem.case.caseView.createdAt', { @@ -45,6 +45,13 @@ export const DESCRIPTION_REQUIRED = i18n.translate( } ); +export const COMMENT_REQUIRED = i18n.translate( + 'xpack.siem.case.caseView.commentFieldRequiredError', + { + defaultMessage: 'A comment is required.', + } +); + export const EDIT = i18n.translate('xpack.siem.case.caseView.edit', { defaultMessage: 'Edit', }); @@ -58,15 +65,11 @@ export const LAST_UPDATED = i18n.translate('xpack.siem.case.caseView.updatedAt', }); export const PAGE_SUBTITLE = i18n.translate('xpack.siem.case.caseView.pageSubtitle', { - defaultMessage: 'Case Workflow Management within the Elastic SIEM', + defaultMessage: 'Cases within the Elastic SIEM', }); export const PAGE_TITLE = i18n.translate('xpack.siem.case.pageTitle', { - defaultMessage: 'Case Workflows', -}); - -export const PREVIEW = i18n.translate('xpack.siem.case.caseView.preview', { - defaultMessage: 'Preview', + defaultMessage: 'Cases', }); export const STATE = i18n.translate('xpack.siem.case.caseView.state', { @@ -77,6 +80,10 @@ export const SUBMIT = i18n.translate('xpack.siem.case.caseView.submit', { defaultMessage: 'Submit', }); +export const CREATE_CASE = i18n.translate('xpack.siem.case.caseView.createCase', { + defaultMessage: 'Create case', +}); + export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { defaultMessage: 'Tags', }); @@ -104,3 +111,18 @@ export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate( export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.siem.case.configureCasesButton', { defaultMessage: 'Configure cases', }); + +export const ADD_COMMENT = i18n.translate('xpack.siem.case.caseView.comment.addComment', { + defaultMessage: 'Add comment', +}); + +export const ADD_COMMENT_HELP_TEXT = i18n.translate( + 'xpack.siem.case.caseView.comment.addCommentHelpText', + { + defaultMessage: 'Add a new comment...', + } +); + +export const SAVE = i18n.translate('xpack.siem.case.caseView.description.save', { + defaultMessage: 'Save', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index cc5e9b38eb2f8cc..abbaa6d6192eed1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -18,7 +18,7 @@ import React, { ChangeEvent, useCallback, useEffect, useState, useRef } from 're import styled from 'styled-components'; import * as RuleI18n from '../../translations'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; interface AddItemProps { addText: string; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index 1cc7bba5558db7c..f921c29c06ab0aa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -19,7 +19,7 @@ import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/searc import { useKibana } from '../../../../../lib/kibana'; import { IMitreEnterpriseAttack } from '../../types'; import { FieldValueTimeline } from '../pick_timeline'; -import { FormSchema } from '../../../../shared_imports'; +import { FormSchema } from '../../../../../shared_imports'; import { ListItems } from './types'; import { buildQueryBarDescription, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index b49126c8c0fe0bf..e87dba251ed6dd6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -20,7 +20,7 @@ import styled from 'styled-components'; import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; import * as Rulei18n from '../../translations'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import { threatDefault } from '../step_about_rule/default_value'; import { IMitreEnterpriseAttack } from '../../types'; import { MyAddItemButton } from '../add_item_form'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx index 56cb02c9ec81787..923ec3a7f0066f5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx @@ -8,7 +8,7 @@ import { EuiFormRow } from '@elastic/eui'; import React, { useCallback, useEffect, useState } from 'react'; import { SearchTimelineSuperSelect } from '../../../../../components/timeline/search_super_select'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; export interface FieldValueTimeline { id: string | null; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index fbe854c1ee346f6..5886a76182eec4a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -29,7 +29,7 @@ import { convertKueryToElasticSearchQuery } from '../../../../../lib/keury'; import { useKibana } from '../../../../../lib/kibana'; import { TimelineModel } from '../../../../../store/timeline/model'; import { useSavedQueryServices } from '../../../../../utils/saved_query_services'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import * as i18n from './translations'; export interface FieldValueQueryBar { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx index ffb6c4eda324384..1b7d17016f83c31 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx @@ -16,7 +16,7 @@ import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports'; +import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import * as I18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index 431d793d6e68a5b..d93c057506ca719 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -30,7 +30,7 @@ import { getUseField, UseField, useForm, -} from '../../../../shared_imports'; +} from '../../../../../shared_imports'; import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data'; import { stepAboutDefaultValue } from './default_value'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 27887bcbbe60021..42cf1e0d9564993 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -13,7 +13,7 @@ import { FormSchema, ValidationFunc, ERROR_CODE, -} from '../../../../shared_imports'; +} from '../../../../../shared_imports'; import { isMitreAttackInvalid } from '../mitre/helpers'; import { OptionalFieldLabel } from '../optional_field_label'; import { isUrlInvalid } from './helpers'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index 773eb44efb26c59..837bc79e968e82c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -33,7 +33,7 @@ import { getUseField, UseField, useForm, -} from '../../../../shared_imports'; +} from '../../../../../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index bb178d7197069c0..e202ff030cd9053 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -17,7 +17,7 @@ import { fieldValidators, FormSchema, ValidationFunc, -} from '../../../../shared_imports'; +} from '../../../../../shared_imports'; import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; const { emptyField } = fieldValidators; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index 2e2c7e068dd857b..e9632966fdfaf9f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -12,7 +12,7 @@ import { setFieldValue } from '../../helpers'; import { RuleStep, RuleStepProps, ScheduleStepRule } from '../../types'; import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; -import { Form, UseField, useForm } from '../../../../shared_imports'; +import { Form, UseField, useForm } from '../../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; import { schema } from './schema'; import * as I18n from './translations'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx index 9932e4f6ef43563..8fbfdf5f25a51cf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { OptionalFieldLabel } from '../optional_field_label'; -import { FormSchema } from '../../../../shared_imports'; +import { FormSchema } from '../../../../../shared_imports'; export const schema: FormSchema = { interval: { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index c985045b1897b33..d816c7e867057c6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -17,7 +17,7 @@ import { displaySuccessToast, useStateToaster } from '../../../../components/toa import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useUserInfo } from '../../components/user_info'; import { AccordionTitle } from '../components/accordion_title'; -import { FormData, FormHook } from '../../../shared_imports'; +import { FormData, FormHook } from '../../../../shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 0fac4641e54a7b7..5e0e4223e3e2729 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -26,7 +26,7 @@ import { displaySuccessToast, useStateToaster } from '../../../../components/toa import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useUserInfo } from '../../components/user_info'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; -import { FormHook, FormData } from '../../../shared_imports'; +import { FormHook, FormData } from '../../../../shared_imports'; import { StepPanel } from '../components/step_panel'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 3fab456d856caca..85f3bcbd236e901 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -11,7 +11,7 @@ import { useLocation } from 'react-router-dom'; import { Filter } from '../../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; -import { FormData, FormHook, FormSchema } from '../../shared_imports'; +import { FormData, FormHook, FormSchema } from '../../../shared_imports'; import { AboutStepRule, DefineStepRule, IMitreEnterpriseAttack, ScheduleStepRule } from './types'; interface GetStepsData { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index b2650dcc2b77eef..34df20de1e461cc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -6,7 +6,7 @@ import { Filter } from '../../../../../../../../src/plugins/data/common'; import { FieldValueQueryBar } from './components/query_bar'; -import { FormData, FormHook } from '../../shared_imports'; +import { FormData, FormHook } from '../../../shared_imports'; import { FieldValueTimeline } from './components/pick_timeline'; export interface EuiBasicTableSortTypes { diff --git a/x-pack/legacy/plugins/siem/public/pages/home/translations.ts b/x-pack/legacy/plugins/siem/public/pages/home/translations.ts index 581c81d9f98a00e..f2bcaa07b1a256b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/home/translations.ts @@ -27,5 +27,5 @@ export const TIMELINES = i18n.translate('xpack.siem.navigation.timelines', { }); export const CASE = i18n.translate('xpack.siem.navigation.case', { - defaultMessage: 'Case', + defaultMessage: 'Cases', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/shared_imports.ts b/x-pack/legacy/plugins/siem/public/shared_imports.ts similarity index 52% rename from x-pack/legacy/plugins/siem/public/pages/shared_imports.ts rename to x-pack/legacy/plugins/siem/public/shared_imports.ts index a41f121b3692651..edd7812b3bd1695 100644 --- a/x-pack/legacy/plugins/siem/public/pages/shared_imports.ts +++ b/x-pack/legacy/plugins/siem/public/shared_imports.ts @@ -17,7 +17,7 @@ export { UseField, useForm, ValidationFunc, -} from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; -export { Field } from '../../../../../../src/plugins/es_ui_shared/static/forms/components'; -export { fieldValidators } from '../../../../../../src/plugins/es_ui_shared/static/forms/helpers'; -export { ERROR_CODE } from '../../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; +} from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; +export { Field } from '../../../../../src/plugins/es_ui_shared/static/forms/components'; +export { fieldValidators } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers'; +export { ERROR_CODE } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts index 5bfd121691ab4eb..6b4e3c194eb823e 100644 --- a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts @@ -28,6 +28,7 @@ describe('UPDATE comment', () => { }, body: { comment: 'Update my comment', + version: 'WzEsMV0=', }, }); @@ -37,6 +38,24 @@ describe('UPDATE comment', () => { expect(response.status).toEqual(200); expect(response.payload.comment).toEqual('Update my comment'); }); + it(`Fails with 409 if version does not match`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'patch', + params: { + id: 'mock-comment-1', + }, + body: { + comment: 'Update my comment', + version: 'badv=', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(409); + }); it(`Returns an error if updateComment throws`, async () => { const request = httpServerMock.createKibanaRequest({ path: '/api/cases/comment/{id}', diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts index 468abc8e7226f79..765f9c722219f30 100644 --- a/x-pack/plugins/case/server/routes/api/schema.ts +++ b/x-pack/plugins/case/server/routes/api/schema.ts @@ -15,6 +15,11 @@ export const NewCommentSchema = schema.object({ comment: schema.string(), }); +export const UpdateCommentArguments = schema.object({ + comment: schema.string(), + version: schema.string(), +}); + export const CommentSchema = schema.object({ comment: schema.string(), created_at: schema.string(), diff --git a/x-pack/plugins/case/server/routes/api/update_comment.ts b/x-pack/plugins/case/server/routes/api/update_comment.ts index 815f44a14e2e704..9f99253f766296f 100644 --- a/x-pack/plugins/case/server/routes/api/update_comment.ts +++ b/x-pack/plugins/case/server/routes/api/update_comment.ts @@ -5,9 +5,12 @@ */ import { schema } from '@kbn/config-schema'; +import { SavedObject } from 'kibana/server'; +import Boom from 'boom'; import { wrapError } from './utils'; -import { NewCommentSchema } from './schema'; +import { UpdateCommentArguments } from './schema'; import { RouteDeps } from '.'; +import { CommentAttributes } from './types'; export function initUpdateCommentApi({ caseService, router }: RouteDeps) { router.patch( @@ -17,20 +20,45 @@ export function initUpdateCommentApi({ caseService, router }: RouteDeps) { params: schema.object({ id: schema.string(), }), - body: NewCommentSchema, + body: UpdateCommentArguments, }, }, async (context, request, response) => { + let theComment: SavedObject<CommentAttributes>; + try { + theComment = await caseService.getComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + if (request.body.version !== theComment.version) { + return response.customError( + wrapError( + Boom.conflict( + 'This comment has been updated. Please refresh before saving additional updates.' + ) + ) + ); + } + if (request.body.comment === theComment.attributes.comment) { + return response.customError( + wrapError(Boom.notAcceptable('Comment is identical to current version.')) + ); + } try { const updatedComment = await caseService.updateComment({ client: context.core.savedObjects.client, commentId: request.params.id, updatedAttributes: { - ...request.body, + comment: request.body.comment, updated_at: new Date().toISOString(), }, }); - return response.ok({ body: updatedComment.attributes }); + return response.ok({ + body: { ...updatedComment.attributes, version: updatedComment.version }, + }); } catch (error) { return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 21500c4db9c3464..a97cf608abc7128 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10986,7 +10986,6 @@ "xpack.siem.editDataProvider.doesNotExistLabel": "存在しません", "xpack.siem.editDataProvider.existsLabel": "存在する", "xpack.siem.editDataProvider.fieldLabel": "フィールド", - "xpack.siem.editDataProvider.fieldPlaceholder": "フィールドを選択", "xpack.siem.editDataProvider.isLabel": "が", "xpack.siem.editDataProvider.isNotLabel": "is not", "xpack.siem.editDataProvider.operatorLabel": "演算子", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c9e7ea1ec80de3a..e6055680e1240b0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10986,7 +10986,6 @@ "xpack.siem.editDataProvider.doesNotExistLabel": "不存在", "xpack.siem.editDataProvider.existsLabel": "存在", "xpack.siem.editDataProvider.fieldLabel": "字段", - "xpack.siem.editDataProvider.fieldPlaceholder": "选择字段", "xpack.siem.editDataProvider.isLabel": "是", "xpack.siem.editDataProvider.isNotLabel": "不是", "xpack.siem.editDataProvider.operatorLabel": "运算符", From 2378d8a0fdd425920d8321aaae729e0dee95013b Mon Sep 17 00:00:00 2001 From: Nathan L Smith <nathan.smith@elastic.co> Date: Mon, 2 Mar 2020 17:36:43 -0600 Subject: [PATCH 04/22] Service map language icons (#58633) Add icons as described in #56235. Also: * Add double-border and ghost "shadow" on nodes * Add framework name capability to popover metrics --- .../app/ServiceMap/Cytoscape.stories.tsx | 188 ++++++++++++++---- .../app/ServiceMap/Popover/Contents.tsx | 6 +- .../ServiceMap/Popover/Popover.stories.tsx | 1 + .../Popover/ServiceMetricFetcher.tsx | 10 +- .../ServiceMap/Popover/ServiceMetricList.tsx | 34 ++-- .../app/ServiceMap/cytoscapeOptions.ts | 10 +- .../app/ServiceMap/get_cytoscape_elements.ts | 3 +- .../public/components/app/ServiceMap/icons.ts | 55 +++-- .../app/ServiceMap/icons/default.svg | 3 + .../app/ServiceMap/icons/dot-net.svg | 127 ++++++++++++ .../components/app/ServiceMap/icons/go.svg | 11 + .../components/app/ServiceMap/icons/java.svg | 7 + .../app/ServiceMap/icons/nodejs.svg | 46 +++++ .../components/app/ServiceMap/icons/php.svg | 18 ++ .../app/ServiceMap/icons/python.svg | 19 ++ .../components/app/ServiceMap/icons/ruby.svg | 125 ++++++++++++ .../components/app/ServiceMap/icons/rumjs.svg | 3 + .../elasticsearch_fieldnames.test.ts.snap | 6 + .../apm/common/elasticsearch_fieldnames.ts | 1 + x-pack/plugins/apm/common/service_map.ts | 1 + .../server/lib/service_map/get_service_map.ts | 14 +- x-pack/plugins/apm/server/lib/services/map.ts | 88 -------- 22 files changed, 599 insertions(+), 177 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg delete mode 100644 x-pack/plugins/apm/server/lib/services/map.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 731555694bff7ee..52941391ca36415 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -4,51 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiCard, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { storiesOf } from '@storybook/react'; import cytoscape from 'cytoscape'; import React from 'react'; import { Cytoscape } from './Cytoscape'; - -const elements: cytoscape.ElementDefinition[] = [ - { - data: { - id: 'opbeans-python', - label: 'opbeans-python', - agentName: 'python', - type: 'service' - } - }, - { - data: { - id: 'opbeans-node', - label: 'opbeans-node', - agentName: 'nodejs', - type: 'service' - } - }, - { - data: { - id: 'opbeans-ruby', - label: 'opbeans-ruby', - agentName: 'ruby', - type: 'service' - } - }, - { data: { source: 'opbeans-python', target: 'opbeans-node' } }, - { - data: { - bidirectional: true, - source: 'opbeans-python', - target: 'opbeans-ruby' - } - } -]; -const height = 300; -const serviceName = 'opbeans-python'; +import { iconForNode } from './icons'; storiesOf('app/ServiceMap/Cytoscape', module).add( 'example', () => { + const elements: cytoscape.ElementDefinition[] = [ + { + data: { + id: 'opbeans-python', + label: 'opbeans-python', + agentName: 'python', + type: 'service' + } + }, + { + data: { + id: 'opbeans-node', + label: 'opbeans-node', + agentName: 'nodejs', + type: 'service' + } + }, + { + data: { + id: 'opbeans-ruby', + label: 'opbeans-ruby', + agentName: 'ruby', + type: 'service' + } + }, + { data: { source: 'opbeans-python', target: 'opbeans-node' } }, + { + data: { + bidirectional: true, + source: 'opbeans-python', + target: 'opbeans-ruby' + } + } + ]; + const height = 300; + const serviceName = 'opbeans-python'; return ( <Cytoscape elements={elements} @@ -59,6 +60,119 @@ storiesOf('app/ServiceMap/Cytoscape', module).add( }, { info: { + propTables: false, + source: false + } + } +); + +storiesOf('app/ServiceMap/Cytoscape', module).add( + 'node icons', + () => { + const cy = cytoscape(); + const elements = [ + { data: { id: 'default', label: 'default', type: undefined } }, + { data: { id: 'cache', label: 'cache', type: 'cache' } }, + { data: { id: 'database', label: 'database', type: 'database' } }, + { data: { id: 'external', label: 'external', type: 'external' } }, + { data: { id: 'messaging', label: 'messaging', type: 'messaging' } }, + + { + data: { + id: 'dotnet', + label: 'dotnet service', + type: 'service', + agentName: 'dotnet' + } + }, + { + data: { + id: 'go', + label: 'go service', + type: 'service', + agentName: 'go' + } + }, + { + data: { + id: 'java', + label: 'java service', + type: 'service', + agentName: 'java' + } + }, + { + data: { + id: 'js-base', + label: 'js-base service', + type: 'service', + agentName: 'js-base' + } + }, + { + data: { + id: 'nodejs', + label: 'nodejs service', + type: 'service', + agentName: 'nodejs' + } + }, + { + data: { + id: 'php', + label: 'php service', + type: 'service', + agentName: 'php' + } + }, + { + data: { + id: 'python', + label: 'python service', + type: 'service', + agentName: 'python' + } + }, + { + data: { + id: 'ruby', + label: 'ruby service', + type: 'service', + agentName: 'ruby' + } + } + ]; + cy.add(elements); + + return ( + <EuiFlexGroup gutterSize="l" wrap={true}> + {cy.nodes().map(node => ( + <EuiFlexItem key={node.data('id')}> + <EuiCard + description={ + <pre> + agentName: {node.data('agentName') || 'undefined'}, type:{' '} + {node.data('type') || 'undefined'} + </pre> + } + icon={ + <img + alt={node.data('label')} + src={iconForNode(node)} + height={80} + width={80} + /> + } + title={node.data('label')} + /> + </EuiFlexItem> + ))} + </EuiFlexGroup> + ); + }, + { + info: { + propTables: false, source: false } } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index f1c53673c87552a..405bd855898b7aa 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -35,6 +35,7 @@ export function Contents({ onFocusClick, selectedNodeServiceName }: ContentsProps) { + const frameworkName = selectedNodeData.frameworkName; return ( <EuiFlexGroup direction="column" @@ -49,7 +50,10 @@ export function Contents({ </EuiFlexItem> <EuiFlexItem> {isService ? ( - <ServiceMetricFetcher serviceName={selectedNodeServiceName} /> + <ServiceMetricFetcher + frameworkName={frameworkName} + serviceName={selectedNodeServiceName} + /> ) : ( <Info {...selectedNodeData} /> )} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index e5962afd76eb8db..23e9e737be9a659 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -16,6 +16,7 @@ storiesOf('app/ServiceMap/Popover/ServiceMetricList', module) avgRequestsPerMinute={164.47222031860858} avgCpuUsage={0.32809666568309237} avgMemoryUsage={0.5504868173242986} + frameworkName="Spring" numInstances={2} isLoading={false} /> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx index b0a5e892b5a7e71..697aa6a1b652b8d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx @@ -11,10 +11,12 @@ import { useUrlParams } from '../../../../hooks/useUrlParams'; import { ServiceMetricList } from './ServiceMetricList'; interface ServiceMetricFetcherProps { + frameworkName?: string; serviceName: string; } export function ServiceMetricFetcher({ + frameworkName, serviceName }: ServiceMetricFetcherProps) { const { @@ -37,5 +39,11 @@ export function ServiceMetricFetcher({ ); const isLoading = status === 'loading'; - return <ServiceMetricList {...data} isLoading={isLoading} />; + return ( + <ServiceMetricList + {...data} + frameworkName={frameworkName} + isLoading={isLoading} + /> + ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx index 3a6b4c5ebcaac26..056af68cc8173c4 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx @@ -30,6 +30,10 @@ function LoadingSpinner() { ); } +const BadgeRow = styled(EuiFlexItem)` + padding-bottom: ${lightTheme.gutterTypes.gutterSmall}; +`; + const ItemRow = styled('tr')` line-height: 2; `; @@ -44,6 +48,7 @@ const ItemDescription = styled('td')` `; interface ServiceMetricListProps extends ServiceNodeMetrics { + frameworkName?: string; isLoading: boolean; } @@ -53,6 +58,7 @@ export function ServiceMetricList({ avgErrorsPerMinute, avgCpuUsage, avgMemoryUsage, + frameworkName, numInstances, isLoading }: ServiceMetricListProps) { @@ -106,23 +112,27 @@ export function ServiceMetricList({ : null } ]; + const showBadgeRow = frameworkName || numInstances > 1; + return isLoading ? ( <LoadingSpinner /> ) : ( <> - {numInstances && numInstances > 1 && ( - <EuiFlexItem> - <div> - <EuiBadge iconType="apps" color="hollow"> - {i18n.translate('xpack.apm.serviceMap.numInstancesMetric', { - values: { numInstances }, - defaultMessage: '{numInstances} instances' - })} - </EuiBadge> - </div> - </EuiFlexItem> + {showBadgeRow && ( + <BadgeRow> + <EuiFlexGroup gutterSize="none"> + {frameworkName && <EuiBadge>{frameworkName}</EuiBadge>} + {numInstances > 1 && ( + <EuiBadge iconType="apps" color="hollow"> + {i18n.translate('xpack.apm.serviceMap.numInstancesMetric', { + values: { numInstances }, + defaultMessage: '{numInstances} instances' + })} + </EuiBadge> + )} + </EuiFlexGroup> + </BadgeRow> )} - <table> <tbody> {listItems.map( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index af5bd17f71ca440..8411169dbc9444c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -42,19 +42,23 @@ const style: cytoscape.Stylesheet[] = [ 'background-image': (el: cytoscape.NodeSingular) => iconForNode(el) ?? defaultIcon, 'background-height': (el: cytoscape.NodeSingular) => - isService(el) ? '85%' : '40%', + isService(el) ? '60%' : '40%', 'background-width': (el: cytoscape.NodeSingular) => - isService(el) ? '85%' : '40%', + isService(el) ? '60%' : '40%', 'border-color': (el: cytoscape.NodeSingular) => el.hasClass('primary') || el.selected() ? theme.euiColorPrimary : theme.euiColorMediumShade, - 'border-width': 1, + 'border-width': 2, color: theme.textColors.default, // theme.euiFontFamily doesn't work here for some reason, so we're just // specifying a subset of the fonts for the label text. 'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif', 'font-size': theme.euiFontSizeXS, + ghost: 'yes', + 'ghost-offset-x': 0, + 'ghost-offset-y': 2, + 'ghost-opacity': 0.15, height: nodeHeight, label: 'data(label)', 'min-zoomed-font-size': theme.euiSizeL, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts index 2403ed047cbc05d..bc619b1ecdfe5f9 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts @@ -105,7 +105,8 @@ export function getCytoscapeElements( `/services/${node['service.name']}/service-map`, search ), - agentName: node['agent.name'] || node['agent.name'], + agentName: node['agent.name'], + frameworkName: node['service.framework.name'], type: 'service' }; } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts index c637d145639ce9a..1b57cd52082d801 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -4,26 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import theme from '@elastic/eui/dist/eui_theme_light.json'; import cytoscape from 'cytoscape'; import databaseIcon from './icons/database.svg'; import documentsIcon from './icons/documents.svg'; +import dotNetIcon from './icons/dot-net.svg'; import globeIcon from './icons/globe.svg'; +import goIcon from './icons/go.svg'; +import javaIcon from './icons/java.svg'; +import nodeJsIcon from './icons/nodejs.svg'; +import phpIcon from './icons/php.svg'; +import pythonIcon from './icons/python.svg'; +import rubyIcon from './icons/ruby.svg'; +import rumJsIcon from './icons/rumjs.svg'; +import defaultIconImport from './icons/default.svg'; -function getAvatarIcon( - text = '', - backgroundColor = 'transparent', - foregroundColor = 'white' -) { - return ( - 'data:image/svg+xml;utf8,' + - encodeURIComponent(`<svg width="80" height="80" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> - <circle cx="40" cy="40" fill="${backgroundColor}" r="40" stroke-width="0" /> - <text fill="${foregroundColor}" font-family="'Inter UI', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Helvetica', 'Arial', sans-serif" font-size="36" text-anchor="middle" x="40" xml:space="preserve" y="52">${text}</text> -</svg> -`) - ); -} +export const defaultIcon = defaultIconImport; // The colors here are taken from the logos of the corresponding technologies const icons: { [key: string]: string } = { @@ -34,18 +29,17 @@ const icons: { [key: string]: string } = { resource: globeIcon }; -const serviceAbbreviations: { [key: string]: string } = { - dotnet: '.N', - go: 'Go', - java: 'Jv', - 'js-base': 'JS', - nodejs: 'No', - python: 'Py', - ruby: 'Rb' +const serviceIcons: { [key: string]: string } = { + dotnet: dotNetIcon, + go: goIcon, + java: javaIcon, + 'js-base': rumJsIcon, + nodejs: nodeJsIcon, + php: phpIcon, + python: pythonIcon, + ruby: rubyIcon }; -export const defaultIcon = getAvatarIcon(); - // IE 11 does not properly load some SVGs, which causes a runtime error and the // map to not work at all. We would prefer to do some kind of feature detection // rather than browser detection, but IE 11 does support SVG, just not well @@ -61,15 +55,12 @@ export function iconForNode(node: cytoscape.NodeSingular) { const type = node.data('type'); if (type === 'service') { - return getAvatarIcon( - serviceAbbreviations[node.data('agentName') as string], - node.selected() || node.hasClass('primary') - ? theme.euiColorPrimary - : theme.euiColorDarkestShade - ); + return serviceIcons[node.data('agentName') as string]; } else if (isIE11) { return defaultIcon; - } else { + } else if (icons[type]) { return icons[type]; + } else { + return defaultIcon; } } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg new file mode 100644 index 000000000000000..08bc5331e083b02 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/default.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M16.75 6.165a1.5 1.5 0 00-1.5 0l-7.392 4.268a1.5 1.5 0 00-.75 1.3v8.535a1.5 1.5 0 00.75 1.299l7.392 4.268a1.5 1.5 0 001.5 0l7.392-4.268a1.5 1.5 0 00.75-1.299v-8.536a1.5 1.5 0 00-.75-1.299L16.75 6.165zm.75-1.299l7.392 4.268a3 3 0 011.5 2.598v8.536a3 3 0 01-1.5 2.598L17.5 27.134a3 3 0 01-3 0l-7.392-4.268a3 3 0 01-1.5-2.598v-8.536a3 3 0 011.5-2.598L14.5 4.866a3 3 0 013 0z" fill="#98A2B3"/> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg new file mode 100644 index 000000000000000..9f7427f0e10017c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/dot-net.svg @@ -0,0 +1,127 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M11.164 13.586c1.145 3.503 1.58 9.753 4.93 9.753.254 0 .512-.025.77-.074-3.045-.71-3.405-6.892-5.264-10.093-.148.135-.293.273-.436.414" fill="url(#paint0_linear)"/> + <path d="M11.6 13.172c1.859 3.201 2.22 9.383 5.265 10.093.239-.044.479-.11.719-.195-2.733-1.339-3.489-7.341-5.6-10.231-.13.108-.258.22-.384.333" fill="url(#paint1_linear)"/> + <path d="M14.278 11.268a4.14 4.14 0 00-.772.074c-.678.128-1.367.42-2.06.862.189.187.37.4.539.635.706-.586 1.407-1.02 2.106-1.28.255-.095.518-.168.786-.218a2.42 2.42 0 00-.6-.073" fill="#14559A"/> + <path d="M19.718 21.836c.29-.233.571-.478.84-.737-1.164-3.487-1.58-9.826-4.954-9.826-.241 0-.485.023-.727.068 3.072.764 3.466 7.45 4.84 10.495" fill="url(#paint2_linear)"/> + <path d="M14.877 11.34a2.425 2.425 0 00-.6-.072l1.327.005a3.95 3.95 0 00-.727.068" fill="#3092C4"/> + <path d="M19.659 22.577a5.018 5.018 0 01-.38-.411 6.977 6.977 0 01-1.695.904 2.609 2.609 0 001.17.269c.63 0 1.129-.075 1.553-.278a2.95 2.95 0 01-.648-.484" fill="#1969BC"/> + <path d="M14.09 11.56c2.753 1.44 2.992 7.959 5.189 10.606.15-.106.295-.216.438-.33-1.374-3.045-1.767-9.731-4.84-10.495-.268.05-.53.124-.786.22" fill="url(#paint3_linear)"/> + <path d="M11.985 12.839c2.11 2.89 2.866 8.892 5.599 10.231a6.975 6.975 0 001.695-.904C17.082 19.519 16.843 13 14.09 11.56c-.699.26-1.4.693-2.106 1.279" fill="url(#paint4_linear)"/> + <path d="M9.814 13.502c-.331.748-.67 1.73-1.078 3.014.813-1.145 1.623-2.131 2.428-2.93a8.789 8.789 0 00-.359-.935c-.345.265-.676.55-.991.85" fill="url(#paint5_linear)"/> + <path d="M11.099 12.435c-.1.07-.197.142-.293.216.128.28.247.594.358.935.143-.14.288-.279.436-.414a5.808 5.808 0 00-.501-.737" fill="#2B74B1"/> + <path d="M11.445 12.204a9.562 9.562 0 00-.347.23c.18.223.346.47.502.738.126-.114.254-.225.385-.333a5.094 5.094 0 00-.54-.635" fill="#125A9E"/> + <path d="M30.218 11.001c-1.556 6.004-4.807 10.825-7.533 12.04h-.005c-.05.023-.1.044-.148.064-.006.004-.012.004-.018.007l-.041.016c-.007.004-.013.005-.02.007-.022.009-.045.015-.067.024-.01.005-.02.007-.028.01a.36.36 0 01-.034.011l-.033.012-.03.01-.057.017c-.009 0-.016.005-.025.007l-.04.01c-.008.005-.016.006-.026.008a.934.934 0 01-.043.01l-.052.012c.125.046.257.07.39.069 2.585 0 5.19-4.632 9.503-12.335h-1.693v.001z" fill="url(#paint6_linear)"/> + <path d="M8.175 11.512c.002 0 .004-.004.005-.004.002 0 .005 0 .006-.004h.003l.042-.016c.004 0 .006 0 .008-.004.004 0 .008-.004.011-.005l.045-.016h.003c.034-.01.066-.023.099-.035.004 0 .009-.004.015-.004l.042-.012c.007-.004.013-.004.02-.007l.042-.012c.007 0 .012-.004.017-.005.047-.013.094-.025.142-.036.006 0 .012-.005.02-.005a.274.274 0 01.04-.008c.007-.004.014-.004.021-.006.014-.004.027-.005.042-.008h.01l.086-.016h.018c.013-.004.026-.004.04-.007.007 0 .015-.004.022-.004.013 0 .025-.004.038-.006.007 0 .013 0 .021-.004.03-.004.061-.005.093-.008a2.87 2.87 0 00-.275-.014c-2.91 0-6.921 5.4-8.728 12.395h.349a101.37 101.37 0 001.572-2.922c1.265-4.954 3.842-8.333 6.131-9.228" fill="url(#paint7_linear)"/> + <path d="M9.814 13.502a12.1 12.1 0 01.991-.851 4.68 4.68 0 00-.236-.457c-.266.323-.51.75-.755 1.308M10.053 11.553c.19.16.36.379.516.641.05-.06.099-.116.15-.168a2.921 2.921 0 00-.666-.473" fill="#0D82CA"/> + <path d="M2.044 20.74c2.654-5.115 3.912-8.352 6.131-9.228-2.288.895-4.866 4.274-6.131 9.227" fill="url(#paint8_linear)"/> + <path d="M10.72 12.026c-.053.053-.102.108-.151.168.087.148.166.3.236.457.097-.074.195-.146.293-.216a3.989 3.989 0 00-.379-.409" fill="#127BCA"/> + <path d="M3.249 23.638c-.017.004-.033.004-.048.006h-.01c-.013 0-.028.004-.04.004h-.007c-.033.004-.066.004-.098.007h-.01c2.686-.075 3.914-1.42 4.524-3.371.463-1.48.843-2.725 1.177-3.77C7.61 18.097 6.48 19.985 5.345 22.1c-.548 1.02-1.382 1.445-2.096 1.537" fill="url(#paint9_linear)"/> + <path d="M3.249 23.637c.714-.09 1.548-.516 2.096-1.536 1.136-2.114 2.267-4.002 3.39-5.586.41-1.284.747-2.266 1.079-3.014-2.241 2.133-4.49 5.679-6.565 10.136" fill="url(#paint10_linear)"/> + <path d="M2.044 20.74c-.475.915-.995 1.89-1.573 2.922h1.013c.128-.985.315-1.96.56-2.922" fill="#05A1E6"/> + <path d="M9.033 11.29c-.008 0-.014.005-.021.005-.013.004-.025.004-.038.005-.008 0-.015 0-.023.004-.014.004-.026.005-.04.007-.006 0-.012 0-.017.004l-.086.015h-.01a.315.315 0 01-.042.009c-.007.004-.014.004-.021.005-.014.004-.028.006-.04.008-.008.005-.014.005-.02.005a4.64 4.64 0 00-.142.037c-.005 0-.01.004-.017.005a2.21 2.21 0 00-.043.012c-.006.004-.013.004-.02.006a1.055 1.055 0 00-.042.013c-.005 0-.01.004-.015.004-.033.012-.065.024-.099.034l-.045.016c-.006.004-.011.005-.018.008-.015.004-.03.01-.042.015-.005.004-.01.005-.014.007-2.22.876-3.477 4.113-6.132 9.228a24.646 24.646 0 00-.56 2.921h.143c.4 0 .513-.004.974-.004h.446c.032-.004.064-.004.097-.006h.007c.013 0 .027-.005.04-.005h.01c.015 0 .032-.003.048-.005 2.075-4.457 4.325-8.003 6.565-10.136.247-.558.49-.984.755-1.307a2.476 2.476 0 00-.516-.642s-.005 0-.005-.004l-.032-.015-.031-.016a.344.344 0 01-.03-.015c-.014-.004-.025-.01-.036-.015l-.029-.012a1.243 1.243 0 01-.058-.025l-.025-.01a.868.868 0 01-.044-.017c-.008-.004-.016-.005-.024-.008l-.06-.02h-.006c-.023-.008-.046-.014-.07-.02-.006-.005-.01-.005-.016-.006l-.06-.016c-.004 0-.01-.004-.013-.004a2.13 2.13 0 00-.148-.033c-.005 0-.01-.004-.015-.004a.86.86 0 00-.064-.01c-.005-.004-.008-.004-.013-.004a1.609 1.609 0 00-.076-.01h-.013c-.02-.004-.04-.004-.058-.006-.032.004-.063.005-.093.008" fill="url(#paint11_linear)"/> + <path d="M24.138 14.325c-.51 1.636-.924 2.985-1.284 4.096 1.401-1.929 2.781-4.354 4.096-7.154-1.534.482-2.351 1.584-2.812 3.058z" fill="url(#paint12_linear)"/> + <path d="M22.898 22.94a4.26 4.26 0 01-.213.102c2.726-1.216 5.977-6.038 7.533-12.04h-.315c-3.576 6.388-4.727 10.664-7.005 11.937z" fill="url(#paint13_linear)"/> + <path d="M21.119 22.403c.593-.724 1.076-1.954 1.735-3.982-.76 1.045-1.526 1.943-2.293 2.675 0 .004 0 .004-.004.007.167.5.35.943.56 1.3" fill="#079AE1"/> + <path d="M21.119 22.403a2.43 2.43 0 01-.812.658 2.295 2.295 0 00.967.274h.009c.02 0 .043 0 .065.004h.222c.008 0 .017 0 .025-.004.016 0 .033 0 .049-.004h.024c.017 0 .035-.004.053-.006h.004c.006 0 .011-.004.017-.004.018-.004.038-.006.056-.009h.017l.064-.01h.01l.128-.027c-.352-.129-.647-.433-.9-.865" fill="#1969BC"/> + <path d="M16.093 23.339c.255 0 .513-.025.772-.075.239-.043.479-.11.719-.193a2.614 2.614 0 001.17.268h-2.66z" fill="#1E5CB3"/> + <path d="M18.754 23.339c.63 0 1.129-.075 1.553-.278a2.298 2.298 0 00.967.274h.009c.02 0 .043 0 .065.004h.077-2.673.002z" fill="#1E5CB3"/> + <path d="M21.426 23.339h.146c.008 0 .017 0 .025-.004.016 0 .032 0 .05-.004h.024c.016 0 .035-.004.052-.006h.005c.005 0 .01-.004.016-.004.018-.004.038-.006.056-.009.006 0 .011 0 .018-.004l.064-.01h.01c.043-.008.085-.016.127-.027.125.046.257.07.39.069l-.982.004-.001-.005z" fill="#1D60B5"/> + <path d="M20.559 21.103v-.004c-.269.258-.55.504-.84.736-.144.114-.29.224-.44.33.123.147.249.285.38.411.201.194.415.358.649.484a2.424 2.424 0 00.812-.658c-.21-.357-.393-.799-.56-1.3" fill="#175FAB"/> + <path d="M28.904 11.001h-1.588c-.085.004-.169.007-.251.013-.039.083-.079.166-.117.25-1.314 2.8-2.695 5.225-4.096 7.153-.659 2.028-1.142 3.259-1.735 3.983.253.432.548.736.899.865.01-.004.018-.004.027-.006h.006c.006 0 .013-.004.02-.004l.044-.011c.009-.004.016-.005.026-.008l.039-.01c.008-.004.016-.005.024-.007l.059-.017a.169.169 0 01.029-.01l.033-.012.033-.01c.01-.005.02-.007.028-.01.023-.01.045-.016.068-.025.007 0 .013-.006.02-.007l.041-.016c.005-.004.011-.005.018-.007.048-.02.098-.04.147-.063h.005a4.21 4.21 0 00.214-.102c2.278-1.273 3.429-5.55 7.005-11.938h-1 .002z" fill="url(#paint14_linear)"/> + <path d="M9.126 11.282c.006 0 .01 0 .016.004a.13.13 0 01.042.005h.014l.075.01a1.473 1.473 0 00.077.015c.005 0 .01.003.016.003.049.01.1.02.147.033.005 0 .01.004.013.004l.06.017c.006 0 .01.004.016.005.024.006.047.012.07.02.001 0 .002 0 .005.004l.061.02c.009.005.016.006.024.008.014.007.03.013.044.018.008 0 .016.006.025.01.019.008.039.015.058.024l.03.012a1.675 1.675 0 00.066.03l.03.016c.254.124.488.29.704.492.078-.082.16-.157.25-.226a2.745 2.745 0 00-1.843-.518" fill="#7DCBEC"/> + <path d="M10.72 12.026c.135.127.262.264.378.409.114-.08.23-.157.347-.231a3.65 3.65 0 00-.477-.405c-.088.07-.172.145-.249.227" fill="#5EC5ED"/> + <path d="M9.126 11.282a2.749 2.749 0 011.842.518c.5-.394 1.103-.532 1.94-.532H8.851c.094 0 .185.006.275.014" fill="url(#paint15_linear)"/> + <path d="M12.908 11.268c-.838 0-1.44.138-1.94.531.17.121.33.257.477.405.694-.442 1.383-.735 2.061-.862.254-.049.512-.073.771-.074h-1.369z" fill="url(#paint16_linear)"/> + <path d="M22.854 18.421c.36-1.11.773-2.46 1.285-4.096.46-1.475 1.277-2.577 2.81-3.058.04-.082.079-.167.118-.25-2.499.15-3.667 1.42-4.255 3.303-1.028 3.288-1.65 5.418-2.25 6.776.765-.732 1.531-1.63 2.292-2.675" fill="url(#paint17_linear)"/> + <path d="M26.52 22.463h-.283v.88h-.115v-.88h-.285v-.105h.683v.105zm1.16.88h-.115v-.661c0-.052.004-.116.01-.191a.68.68 0 01-.031.096l-.335.756h-.057l-.336-.75a.526.526 0 01-.03-.102h-.004c.004.04.005.104.005.192v.66h-.11v-.985h.152l.302.688a.902.902 0 01.045.118h.004l.047-.121.309-.685h.145v.985z" fill="#000"/> + <defs> + <linearGradient id="paint0_linear" x1="14.014" y1="11.188" x2="14.014" y2="26.351" gradientUnits="userSpaceOnUse"> + <stop stop-color="#0994DC"/> + <stop offset=".35" stop-color="#66CEF5"/> + <stop offset=".846" stop-color="#127BCA"/> + <stop offset="1" stop-color="#127BCA"/> + </linearGradient> + <linearGradient id="paint1_linear" x1="14.592" y1="10.804" x2="14.592" y2="26.353" gradientUnits="userSpaceOnUse"> + <stop stop-color="#0E76BC"/> + <stop offset=".36" stop-color="#36AEE8"/> + <stop offset=".846" stop-color="#00ADEF"/> + <stop offset="1" stop-color="#00ADEF"/> + </linearGradient> + <linearGradient id="paint2_linear" x1="17.717" y1="22.99" x2="17.717" y2="10.617" gradientUnits="userSpaceOnUse"> + <stop stop-color="#1C63B7"/> + <stop offset=".5" stop-color="#33BDF2"/> + <stop offset="1" stop-color="#33BDF2" stop-opacity=".42"/> + </linearGradient> + <linearGradient id="paint3_linear" x1="16.904" y1="9.228" x2="16.904" y2="25.373" gradientUnits="userSpaceOnUse"> + <stop stop-color="#166AB8"/> + <stop offset=".4" stop-color="#36AEE8"/> + <stop offset=".846" stop-color="#0798DD"/> + <stop offset="1" stop-color="#0798DD"/> + </linearGradient> + <linearGradient id="paint4_linear" x1="15.632" y1="9.313" x2="15.632" y2="26.48" gradientUnits="userSpaceOnUse"> + <stop stop-color="#124379"/> + <stop offset=".39" stop-color="#1487CB"/> + <stop offset=".78" stop-color="#165197"/> + <stop offset="1" stop-color="#165197"/> + </linearGradient> + <linearGradient id="paint5_linear" x1="8.736" y1="14.583" x2="11.164" y2="14.583" gradientUnits="userSpaceOnUse"> + <stop stop-color="#33BDF2" stop-opacity=".698"/> + <stop offset="1" stop-color="#1DACD8"/> + </linearGradient> + <linearGradient id="paint6_linear" x1="26.965" y1="22.679" x2="26.965" y2="10.627" gradientUnits="userSpaceOnUse"> + <stop stop-color="#136AB4"/> + <stop offset=".6" stop-color="#59CAF5" stop-opacity=".549"/> + <stop offset="1" stop-color="#59CAF5" stop-opacity=".235"/> + </linearGradient> + <linearGradient id="paint7_linear" x1=".123" y1="17.463" x2="9.126" y2="17.463" gradientUnits="userSpaceOnUse"> + <stop stop-color="#05A1E6" stop-opacity=".247"/> + <stop offset="1" stop-color="#05A1E6"/> + </linearGradient> + <linearGradient id="paint8_linear" x1="5.109" y1="22.983" x2="5.109" y2="10.642" gradientUnits="userSpaceOnUse"> + <stop stop-color="#318ED5"/> + <stop offset="1" stop-color="#38A7E4"/> + </linearGradient> + <linearGradient id="paint9_linear" x1="5.886" y1="23.026" x2="5.886" y2="11.03" gradientUnits="userSpaceOnUse"> + <stop stop-color="#05A1E6"/> + <stop offset="1" stop-color="#05A1E6" stop-opacity=".549"/> + </linearGradient> + <linearGradient id="paint10_linear" x1="6.531" y1="23.639" x2="6.531" y2="13.502" gradientUnits="userSpaceOnUse"> + <stop stop-color="#1959A6"/> + <stop offset=".5" stop-color="#05A1E6"/> + <stop offset=".918" stop-color="#7EC5EA"/> + <stop offset="1" stop-color="#7EC5EA"/> + </linearGradient> + <linearGradient id="paint11_linear" x1="6.92" y1="22.991" x2="3.484" y2="12.49" gradientUnits="userSpaceOnUse"> + <stop stop-color="#165096"/> + <stop offset="1" stop-color="#0D82CA"/> + </linearGradient> + <linearGradient id="paint12_linear" x1="24.901" y1="16.775" x2="24.901" y2="11.031" gradientUnits="userSpaceOnUse"> + <stop stop-color="#05A1E6"/> + <stop offset=".874" stop-color="#0495D6"/> + <stop offset="1" stop-color="#0495D6"/> + </linearGradient> + <linearGradient id="paint13_linear" x1="27.601" y1="10.5" x2="22.248" y2="22.386" gradientUnits="userSpaceOnUse"> + <stop stop-color="#38A7E4" stop-opacity=".329"/> + <stop offset=".962" stop-color="#0E88D3"/> + <stop offset="1" stop-color="#0E88D3"/> + </linearGradient> + <linearGradient id="paint14_linear" x1="25.511" y1="10.965" x2="25.511" y2="23.496" gradientUnits="userSpaceOnUse"> + <stop stop-color="#168CD4"/> + <stop offset=".5" stop-color="#1C87CC"/> + <stop offset="1" stop-color="#154B8D"/> + </linearGradient> + <linearGradient id="paint15_linear" x1="9.527" y1="10.707" x2="9.582" y2="12.022" gradientUnits="userSpaceOnUse"> + <stop stop-color="#97D6EE"/> + <stop offset=".703" stop-color="#55C1EA"/> + <stop offset="1" stop-color="#55C1EA"/> + </linearGradient> + <linearGradient id="paint16_linear" x1="11.196" y1="12.145" x2="11.442" y2="10.753" gradientUnits="userSpaceOnUse"> + <stop stop-color="#7ACCEC"/> + <stop offset="1" stop-color="#3FB7ED"/> + </linearGradient> + <linearGradient id="paint17_linear" x1="23.814" y1="11.658" x2="23.814" y2="23.914" gradientUnits="userSpaceOnUse"> + <stop stop-color="#1DA7E7"/> + <stop offset="1" stop-color="#37ABE7" stop-opacity="0"/> + </linearGradient> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg new file mode 100644 index 000000000000000..fb171e2813fac51 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/go.svg @@ -0,0 +1,11 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clip-path="url(#clip0)" fill="#00ADD8"> + <path d="M2.414 13.15c-.062 0-.077-.031-.046-.078l.324-.417c.031-.047.109-.078.17-.078h5.525c.062 0 .077.047.046.093l-.263.402c-.03.047-.108.093-.155.093l-5.601-.015zM.077 14.573c-.061 0-.077-.03-.046-.077l.325-.418c.03-.046.108-.077.17-.077h7.056c.062 0 .093.046.078.093l-.124.371c-.016.062-.078.093-.14.093l-7.319.015zm3.745 1.424c-.062 0-.077-.046-.046-.093l.216-.387c.031-.046.093-.093.155-.093h3.095c.062 0 .093.047.093.109l-.031.371c0 .062-.062.108-.109.108l-3.373-.015zm16.062-3.126c-.975.248-1.64.433-2.6.681-.232.062-.247.077-.448-.155-.232-.263-.403-.433-.728-.588-.975-.48-1.918-.34-2.8.232-1.053.681-1.594 1.687-1.579 2.94.016 1.238.867 2.26 2.09 2.43 1.051.14 1.933-.232 2.63-1.021.139-.17.263-.356.418-.573H13.88c-.325 0-.402-.201-.294-.464.201-.48.573-1.285.79-1.687a.418.418 0 01.386-.247h5.633c-.031.417-.031.835-.093 1.253a6.598 6.598 0 01-1.27 3.033c-1.113 1.47-2.568 2.383-4.41 2.63-1.516.201-2.924-.093-4.162-1.02-1.145-.867-1.795-2.013-1.965-3.436-.201-1.687.294-3.203 1.315-4.534 1.1-1.439 2.554-2.352 4.333-2.677 1.455-.263 2.847-.093 4.1.758.82.542 1.409 1.285 1.796 2.182.093.14.03.217-.155.263z"/> + <path d="M25.006 21.428c-1.408-.03-2.693-.433-3.776-1.361-.913-.79-1.485-1.795-1.671-2.987-.279-1.748.201-3.296 1.253-4.673 1.13-1.486 2.492-2.26 4.333-2.584 1.578-.279 3.064-.124 4.41.789 1.223.836 1.98 1.965 2.182 3.45.263 2.09-.34 3.792-1.78 5.246-1.02 1.037-2.274 1.687-3.713 1.981-.418.077-.836.093-1.238.14zm3.683-6.251c-.016-.201-.016-.356-.047-.51-.278-1.533-1.686-2.4-3.157-2.059-1.439.325-2.367 1.238-2.707 2.693-.279 1.207.309 2.429 1.423 2.924.851.371 1.702.325 2.522-.093 1.223-.634 1.888-1.625 1.966-2.955z"/> + </g> + <defs> + <clipPath id="clip0"> + <path fill="#fff" d="M0 9.333h32v12.275H0z"/> + </clipPath> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg new file mode 100644 index 000000000000000..52a410e2eaa1a64 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/java.svg @@ -0,0 +1,7 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M11.729 24.747s-1.243.71.885.951c2.58.29 3.897.248 6.738-.28 0 0 .748.46 1.792.858-6.371 2.685-14.419-.155-9.415-1.53zm-.779-3.503s-1.394 1.014.736 1.231c2.755.28 4.93.303 8.695-.41 0 0 .52.52 1.338.803-7.702 2.215-16.28.174-10.769-1.624z" fill="#3174B9"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M17.512 15.3c1.57 1.778-.411 3.377-.411 3.377s3.986-2.023 2.155-4.557c-1.71-2.362-3.02-3.536 4.077-7.583 0 0-11.141 2.735-5.82 8.763z" fill="#CA3132"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M25.938 27.338s.92.746-1.013 1.323c-3.677 1.095-15.304 1.425-18.534.044-1.16-.497 1.016-1.186 1.701-1.332.714-.151 1.122-.124 1.122-.124-1.291-.894-8.346 1.756-3.583 2.516 12.988 2.071 23.675-.932 20.307-2.427zm-13.611-9.724s-5.914 1.381-2.094 1.884c1.613.212 4.827.163 7.823-.084a61.883 61.883 0 004.905-.634s-.862.363-1.487.782c-6.007 1.554-17.608.83-14.268-.758 2.824-1.343 5.12-1.19 5.12-1.19zm10.61 5.831c6.105-3.12 3.282-6.117 1.311-5.713a4.702 4.702 0 00-.698.184s.18-.276.522-.395c3.898-1.347 6.896 3.974-1.257 6.082 0 0 .094-.084.122-.158z" fill="#3174B9"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M19.256 0s3.38 3.326-3.207 8.44c-5.283 4.103-1.205 6.442-.002 9.115-3.084-2.736-5.347-5.144-3.83-7.386C14.448 6.879 20.62 5.283 19.257 0z" fill="#CA3132"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M12.927 31.9c5.86.368 14.86-.205 15.073-2.932 0 0-.41 1.033-4.843 1.853-5.002.926-11.172.819-14.83.225 0 0 .75.61 4.6.853z" fill="#3174B9"/> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg new file mode 100644 index 000000000000000..d327b1ba65ad26d --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/nodejs.svg @@ -0,0 +1,46 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <mask id="a" maskUnits="userSpaceOnUse" x="5" y="4" width="22" height="24"> + <path d="M16.101 4.15a1.151 1.151 0 00-1.139 0L5.56 9.579c-.358.201-.559.58-.559.983v10.878c0 .402.223.782.559.983l9.403 5.427a1.152 1.152 0 001.14 0l9.403-5.427c.357-.201.559-.581.559-.983V10.56c0-.402-.224-.782-.559-.983l-9.404-5.427z" fill="#fff"/> + </mask> + <g mask="url(#a)"> + <path d="M16.101 4.15a1.151 1.151 0 00-1.139 0L5.56 9.579c-.358.201-.559.58-.559.983v10.878c0 .402.223.782.559.983l9.403 5.427a1.152 1.152 0 001.14 0l9.403-5.427c.357-.201.559-.581.559-.983V10.56c0-.402-.224-.782-.559-.983l-9.404-5.427z" fill="url(#paint0_linear)"/> + <path d="M25.527 9.577L16.08 4.15a1.55 1.55 0 00-.29-.112L5.2 22.175c.088.107.194.198.313.268l9.448 5.428c.268.157.581.201.871.112L25.773 9.8a1.173 1.173 0 00-.246-.223z" fill="url(#paint1_linear)"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M25.55 22.422c.267-.157.468-.425.558-.715L15.744 4.017c-.268-.045-.559-.023-.804.133L5.559 9.556l10.118 18.45c.147-.024.29-.07.424-.134l9.448-5.45z" fill="url(#paint2_linear)"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M25.55 22.422l-9.426 5.428a1.349 1.349 0 01-.424.134l.178.335 10.453-6.053v-.134l-.268-.447c-.044.313-.245.58-.514.737z" fill="url(#paint3_linear)"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M25.55 22.422l-9.426 5.428a1.349 1.349 0 01-.424.134l.178.335 10.453-6.053v-.134l-.268-.447c-.044.313-.245.58-.514.737z" fill="url(#paint4_linear)"/> + </g> + <defs> + <linearGradient id="paint0_linear" x1="19.363" y1="8.197" x2="9.056" y2="24.392" gradientUnits="userSpaceOnUse"> + <stop stop-color="#41873F"/> + <stop offset=".329" stop-color="#418B3D"/> + <stop offset=".635" stop-color="#419637"/> + <stop offset=".932" stop-color="#3FA92D"/> + <stop offset="1" stop-color="#3FAE2A"/> + </linearGradient> + <linearGradient id="paint1_linear" x1="14.104" y1="17.273" x2="39.918" y2="3.249" gradientUnits="userSpaceOnUse"> + <stop offset=".138" stop-color="#41873F"/> + <stop offset=".403" stop-color="#54A044"/> + <stop offset=".714" stop-color="#66B848"/> + <stop offset=".908" stop-color="#6CC04A"/> + </linearGradient> + <linearGradient id="paint2_linear" x1="4.657" y1="16" x2="26.416" y2="16" gradientUnits="userSpaceOnUse"> + <stop offset=".092" stop-color="#6CC04A"/> + <stop offset=".286" stop-color="#66B848"/> + <stop offset=".597" stop-color="#54A044"/> + <stop offset=".862" stop-color="#41873F"/> + </linearGradient> + <linearGradient id="paint3_linear" x1="4.657" y1="25.02" x2="26.416" y2="25.02" gradientUnits="userSpaceOnUse"> + <stop offset=".092" stop-color="#6CC04A"/> + <stop offset=".286" stop-color="#66B848"/> + <stop offset=".597" stop-color="#54A044"/> + <stop offset=".862" stop-color="#41873F"/> + </linearGradient> + <linearGradient id="paint4_linear" x1="29.586" y1="7.683" x2="24.073" y2="36.568" gradientUnits="userSpaceOnUse"> + <stop stop-color="#41873F"/> + <stop offset=".329" stop-color="#418B3D"/> + <stop offset=".635" stop-color="#419637"/> + <stop offset=".932" stop-color="#3FA92D"/> + <stop offset="1" stop-color="#3FAE2A"/> + </linearGradient> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg new file mode 100644 index 000000000000000..c8af5dc33126939 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/php.svg @@ -0,0 +1,18 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M.45 15.63c0 4.518 6.962 8.18 15.55 8.18 8.588 0 15.55-3.662 15.55-8.18S24.588 7.45 16 7.45C7.412 7.45.45 11.112.45 15.63z" fill="url(#paint0_radial)"/> + <path d="M16 23.203c8.253 0 14.943-3.39 14.943-7.573 0-4.182-6.69-7.573-14.943-7.573-8.252 0-14.943 3.39-14.943 7.573 0 4.182 6.69 7.573 14.943 7.573z" fill="#777BB3"/> + <path d="M8.898 16.569c.679 0 1.186-.125 1.506-.372.317-.244.536-.667.651-1.257.107-.552.066-.937-.121-1.145-.192-.212-.606-.32-1.232-.32H8.618l-.601 3.094h.881zm-3.546 3.81a.168.168 0 01-.166-.2l1.593-8.198a.169.169 0 01.166-.137h3.434c1.079 0 1.882.293 2.387.871.508.581.664 1.394.466 2.414-.08.416-.22.802-.413 1.148a3.841 3.841 0 01-.76.952c-.372.349-.793.601-1.25.75-.45.146-1.029.22-1.719.22H7.7l-.397 2.044a.169.169 0 01-.166.136H5.352z" fill="#000"/> + <path d="M8.757 13.644h.946c.754 0 1.017.166 1.106.264.148.164.175.51.08 1-.106.548-.304.937-.588 1.155-.29.224-.762.337-1.403.337h-.676l.535-2.756zm1.622-1.969H6.945c-.162 0-.3.115-.331.274L5.02 20.146a.338.338 0 00.332.402h1.785c.162 0 .3-.115.332-.273l.37-1.907H9.09c.708 0 1.304-.077 1.77-.229.481-.156.923-.42 1.314-.786.324-.298.59-.632.792-.993.202-.362.347-.765.432-1.198.209-1.075.039-1.935-.505-2.558-.538-.616-1.384-.928-2.514-.928zm-2.567 5.062h1.086c.72 0 1.257-.135 1.609-.406.353-.272.59-.725.714-1.36.118-.608.064-1.038-.162-1.289-.226-.25-.678-.375-1.356-.375H8.479l-.667 3.43zm2.567-4.724c1.033 0 1.786.271 2.26.813.474.543.616 1.3.427 2.271-.078.4-.209.766-.394 1.098a3.663 3.663 0 01-.727.91 3.154 3.154 0 01-1.189.713c-.436.142-.99.213-1.666.213H7.56l-.423 2.18H5.352l1.593-8.198h3.434" fill="#fff"/> + <path d="M17.326 18.2a.168.168 0 01-.165-.201l.705-3.627c.067-.345.05-.593-.047-.697-.06-.064-.238-.172-.765-.172h-1.277l-.886 4.56a.169.169 0 01-.166.137h-1.771a.169.169 0 01-.166-.201L14.38 9.8a.169.169 0 01.166-.136h1.771a.169.169 0 01.166.2l-.384 1.98h1.373c1.047 0 1.756.184 2.17.563.42.387.552 1.007.39 1.84l-.741 3.815a.169.169 0 01-.166.137h-1.8z" fill="#000"/> + <path d="M16.318 9.496h-1.771c-.162 0-.3.115-.332.273l-1.593 8.198a.337.337 0 00.331.401h1.772c.162 0 .3-.114.331-.273l.86-4.423h1.138c.526 0 .637.113.641.117.032.035.074.194.005.55l-.705 3.628a.337.337 0 00.331.401h1.8c.162 0 .3-.114.331-.273l.742-3.814c.174-.896.025-1.568-.443-1.997-.445-.41-1.192-.609-2.283-.609h-1.169l.346-1.777a.337.337 0 00-.332-.402zm0 .338l-.423 2.179h1.578c.993 0 1.678.173 2.055.52.377.346.49.907.34 1.683l-.742 3.815h-1.8l.705-3.627c.08-.413.05-.694-.088-.844-.14-.15-.436-.225-.89-.225h-1.415l-.913 4.696h-1.772l1.594-8.197h1.771z" fill="#fff"/> + <path d="M22.836 16.569c.679 0 1.185-.125 1.506-.372.317-.244.536-.667.65-1.257.108-.552.067-.937-.12-1.145-.192-.212-.606-.32-1.232-.32h-1.084l-.601 3.094h.881zm-3.546 3.81a.168.168 0 01-.166-.2l1.593-8.198a.169.169 0 01.166-.137h3.434c1.079 0 1.882.293 2.387.871.508.581.664 1.393.466 2.414-.081.416-.22.802-.413 1.148-.194.346-.45.667-.76.952-.372.349-.793.601-1.25.75-.45.146-1.029.22-1.719.22h-1.39l-.397 2.044a.169.169 0 01-.166.136H19.29z" fill="#000"/> + <path d="M22.695 13.644h.945c.755 0 1.017.166 1.107.264.147.164.175.51.08 1-.107.548-.304.937-.588 1.155-.29.224-.762.337-1.403.337h-.676l.535-2.756zm1.622-1.969h-3.434c-.162 0-.3.115-.331.274l-1.594 8.197a.338.338 0 00.332.402h1.785c.162 0 .3-.115.331-.273l.37-1.907h1.252c.708 0 1.304-.077 1.77-.229.481-.156.923-.42 1.314-.787.324-.297.59-.631.792-.992.202-.362.347-.765.431-1.198.21-1.075.04-1.936-.504-2.558-.538-.616-1.384-.928-2.514-.928zm-2.567 5.062h1.086c.72 0 1.257-.135 1.609-.406.352-.272.59-.725.713-1.36.119-.608.065-1.038-.161-1.289-.226-.25-.678-.375-1.357-.375h-1.223l-.667 3.43zm2.567-4.724c1.033 0 1.786.271 2.26.813.474.543.616 1.3.427 2.271-.078.4-.21.766-.394 1.098a3.66 3.66 0 01-.727.91 3.154 3.154 0 01-1.189.713c-.436.142-.99.213-1.666.213h-1.53l-.423 2.18H19.29l1.593-8.198h3.434" fill="#fff"/> + <defs> + <radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(9.786 10.326) scale(20.4194)"> + <stop stop-color="#AEB2D5"/> + <stop offset=".3" stop-color="#AEB2D5"/> + <stop offset=".75" stop-color="#484C89"/> + <stop offset="1" stop-color="#484C89"/> + </radialGradient> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg new file mode 100644 index 000000000000000..9b8d0a2836c2894 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/python.svg @@ -0,0 +1,19 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clip-path="url(#clip0)"> + <path d="M15.897 4.007c-6.078 0-5.698 2.635-5.698 2.635l.007 2.73h5.8v.82H7.901s-3.889-.44-3.889 5.691c0 6.133 3.394 5.915 3.394 5.915h2.026v-2.845s-.109-3.395 3.34-3.395h5.753s3.231.053 3.231-3.123v-5.25s.491-3.178-5.86-3.178zm-3.198 1.836a1.043 1.043 0 11.002 2.086 1.043 1.043 0 01-.002-2.086z" fill="url(#paint0_linear)"/> + <path d="M16.07 27.822c6.077 0 5.698-2.635 5.698-2.635l-.007-2.73h-5.8v-.82h8.103s3.89.44 3.89-5.692c0-6.132-3.395-5.915-3.395-5.915h-2.026v2.846s.11 3.394-3.34 3.394h-5.752s-3.232-.052-3.232 3.124v5.25s-.49 3.178 5.86 3.178zm3.198-1.836a1.04 1.04 0 01-1.044-1.043 1.041 1.041 0 011.443-.965 1.042 1.042 0 010 1.929 1.04 1.04 0 01-.4.079z" fill="url(#paint1_linear)"/> + </g> + <defs> + <linearGradient id="paint0_linear" x1="6.314" y1="6.149" x2="18.178" y2="17.894" gradientUnits="userSpaceOnUse"> + <stop stop-color="#387EB8"/> + <stop offset="1" stop-color="#366994"/> + </linearGradient> + <linearGradient id="paint1_linear" x1="13.596" y1="13.691" x2="26.337" y2="25.735" gradientUnits="userSpaceOnUse"> + <stop stop-color="#FFE052"/> + <stop offset="1" stop-color="#FFC331"/> + </linearGradient> + <clipPath id="clip0"> + <path fill="#fff" d="M4 4h24v23.904H4z"/> + </clipPath> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg new file mode 100644 index 000000000000000..fdc54b91f9c29ed --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/ruby.svg @@ -0,0 +1,125 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clip-path="url(#clip0)"> + <path d="M22.51 19.726l-13.64 8.1 17.662-1.198 1.36-17.81-5.381 10.908z" fill="url(#paint0_linear)"/> + <path d="M26.561 26.616l-1.518-10.478-4.135 5.46 5.653 5.018z" fill="url(#paint1_linear)"/> + <path d="M26.582 26.616l-11.122-.873-6.532 2.06 17.654-1.187z" fill="url(#paint2_linear)"/> + <path d="M8.944 27.806l2.779-9.102-6.114 1.307 3.335 7.795z" fill="url(#paint3_linear)"/> + <path d="M20.907 21.628l-2.556-10.014-7.317 6.858 9.873 3.156z" fill="url(#paint4_linear)"/> + <path d="M27.313 11.755l-6.916-5.648-1.926 6.226 8.842-.578z" fill="url(#paint5_linear)"/> + <path d="M24.078 4.093L20.011 6.34l-2.566-2.278 6.633.03z" fill="url(#paint6_linear)"/> + <path d="M4 23.064l1.704-3.107-1.378-3.702L4 23.065z" fill="url(#paint7_linear)"/> + <path d="M4.234 16.138l1.387 3.933 6.026-1.352 6.88-6.394 1.941-6.166L17.411 4l-5.198 1.945c-1.637 1.523-4.815 4.537-4.93 4.593-.113.058-2.098 3.81-3.049 5.6z" fill="#fff"/> + <path d="M9.104 9.071c3.549-3.519 8.124-5.598 9.88-3.826 1.754 1.771-.107 6.076-3.656 9.594s-8.067 5.711-9.822 3.94c-1.756-1.77.048-6.19 3.598-9.708z" fill="url(#paint8_linear)"/> + <path d="M8.944 27.802l2.757-9.13 9.155 2.94c-3.31 3.104-6.992 5.729-11.912 6.19z" fill="url(#paint9_linear)"/> + <path d="M18.539 12.308l2.35 9.31c2.765-2.908 5.247-6.034 6.462-9.9l-8.812.59z" fill="url(#paint10_linear)"/> + <path d="M27.327 11.765c.94-2.839 1.158-6.911-3.278-7.667l-3.64 2.01 6.918 5.657z" fill="url(#paint11_linear)"/> + <path d="M4 23.023c.13 4.686 3.51 4.755 4.95 4.796l-3.326-7.767L4 23.023z" fill="#9E1209"/> + <path d="M18.552 12.322c2.125 1.306 6.407 3.929 6.494 3.977.135.076 1.846-2.886 2.234-4.56l-8.728.583z" fill="url(#paint12_radial)"/> + <path d="M11.697 18.671l3.686 7.11c2.179-1.182 3.885-2.621 5.448-4.164l-9.134-2.946z" fill="url(#paint13_radial)"/> + <path d="M5.61 20.062l-.522 6.217c.985 1.346 2.34 1.463 3.762 1.358-1.028-2.56-3.083-7.68-3.24-7.575z" fill="url(#paint14_linear)"/> + <path d="M20.388 6.123l7.321 1.028c-.39-1.656-1.59-2.724-3.635-3.058l-3.686 2.03z" fill="url(#paint15_linear)"/> + </g> + <defs> + <linearGradient id="paint0_linear" x1="24.992" y1="29.993" x2="19.957" y2="21.091" gradientUnits="userSpaceOnUse"> + <stop stop-color="#FB7655"/> + <stop offset=".41" stop-color="#E42B1E"/> + <stop offset=".99" stop-color="#900"/> + <stop offset="1" stop-color="#900"/> + </linearGradient> + <linearGradient id="paint1_linear" x1="27.503" y1="22.518" x2="20.425" y2="21.135" gradientUnits="userSpaceOnUse"> + <stop stop-color="#871101"/> + <stop offset=".99" stop-color="#911209"/> + <stop offset="1" stop-color="#911209"/> + </linearGradient> + <linearGradient id="paint2_linear" x1="22.305" y1="30.263" x2="22.214" y2="25.774" gradientUnits="userSpaceOnUse"> + <stop stop-color="#871101"/> + <stop offset=".99" stop-color="#911209"/> + <stop offset="1" stop-color="#911209"/> + </linearGradient> + <linearGradient id="paint3_linear" x1="8.666" y1="19.362" x2="10.771" y2="25.533" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".23" stop-color="#E57252"/> + <stop offset=".46" stop-color="#DE3B20"/> + <stop offset=".99" stop-color="#A60003"/> + <stop offset="1" stop-color="#A60003"/> + </linearGradient> + <linearGradient id="paint4_linear" x1="15.593" y1="13.251" x2="15.975" y2="19.93" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".23" stop-color="#E4714E"/> + <stop offset=".56" stop-color="#BE1A0D"/> + <stop offset=".99" stop-color="#A80D00"/> + <stop offset="1" stop-color="#A80D00"/> + </linearGradient> + <linearGradient id="paint5_linear" x1="21.739" y1="7.078" x2="22.297" y2="11.928" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".18" stop-color="#E46342"/> + <stop offset=".4" stop-color="#C82410"/> + <stop offset=".99" stop-color="#A80D00"/> + <stop offset="1" stop-color="#A80D00"/> + </linearGradient> + <linearGradient id="paint6_linear" x1="18.347" y1="5.392" x2="19.134" y2="2.055" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".54" stop-color="#C81F11"/> + <stop offset=".99" stop-color="#BF0905"/> + <stop offset="1" stop-color="#BF0905"/> + </linearGradient> + <linearGradient id="paint7_linear" x1="4.471" y1="17.694" x2="6.529" y2="18.984" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fff"/> + <stop offset=".31" stop-color="#DE4024"/> + <stop offset=".99" stop-color="#BF190B"/> + <stop offset="1" stop-color="#BF190B"/> + </linearGradient> + <linearGradient id="paint8_linear" x1="1.762" y1="22.704" x2="20.255" y2="3.635" gradientUnits="userSpaceOnUse"> + <stop stop-color="#BD0012"/> + <stop offset=".07" stop-color="#fff"/> + <stop offset=".17" stop-color="#fff"/> + <stop offset=".27" stop-color="#C82F1C"/> + <stop offset=".33" stop-color="#820C01"/> + <stop offset=".46" stop-color="#A31601"/> + <stop offset=".72" stop-color="#B31301"/> + <stop offset=".99" stop-color="#E82609"/> + <stop offset="1" stop-color="#E82609"/> + </linearGradient> + <linearGradient id="paint9_linear" x1="15.948" y1="24.625" x2="10.714" y2="22.427" gradientUnits="userSpaceOnUse"> + <stop stop-color="#8C0C01"/> + <stop offset=".54" stop-color="#990C00"/> + <stop offset=".99" stop-color="#A80D0E"/> + <stop offset="1" stop-color="#A80D0E"/> + </linearGradient> + <linearGradient id="paint10_linear" x1="25.529" y1="17.93" x2="20.138" y2="14.101" gradientUnits="userSpaceOnUse"> + <stop stop-color="#7E110B"/> + <stop offset=".99" stop-color="#9E0C00"/> + <stop offset="1" stop-color="#9E0C00"/> + </linearGradient> + <linearGradient id="paint11_linear" x1="27.349" y1="9.781" x2="24.814" y2="7.207" gradientUnits="userSpaceOnUse"> + <stop stop-color="#79130D"/> + <stop offset=".99" stop-color="#9E120B"/> + <stop offset="1" stop-color="#9E120B"/> + </linearGradient> + <linearGradient id="paint14_linear" x1="7.216" y1="27.797" x2="2.671" y2="24.024" gradientUnits="userSpaceOnUse"> + <stop stop-color="#8B2114"/> + <stop offset=".43" stop-color="#9E100A"/> + <stop offset=".99" stop-color="#B3100C"/> + <stop offset="1" stop-color="#B3100C"/> + </linearGradient> + <linearGradient id="paint15_linear" x1="22.648" y1="5.181" x2="23.939" y2="8.445" gradientUnits="userSpaceOnUse"> + <stop stop-color="#B31000"/> + <stop offset=".44" stop-color="#910F08"/> + <stop offset=".99" stop-color="#791C12"/> + <stop offset="1" stop-color="#791C12"/> + </linearGradient> + <radialGradient id="paint12_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(6.07279 0 0 3.17318 21.345 13.573)"> + <stop stop-color="#A80D00"/> + <stop offset=".99" stop-color="#7E0E08"/> + <stop offset="1" stop-color="#7E0E08"/> + </radialGradient> + <radialGradient id="paint13_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(8.07284 0 0 6.28431 12.935 21.576)"> + <stop stop-color="#A30C00"/> + <stop offset=".99" stop-color="#800E08"/> + <stop offset="1" stop-color="#800E08"/> + </radialGradient> + <clipPath id="clip0"> + <path fill="#fff" d="M4 4h24v23.904H4z"/> + </clipPath> + </defs> +</svg> diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg new file mode 100644 index 000000000000000..87043159ed8c32b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/rumjs.svg @@ -0,0 +1,3 @@ +<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M5 20.245l2.438-1.485c.47.84.898 1.55 1.924 1.55.984 0 1.604-.387 1.604-1.894V8.172h2.994V18.46c0 3.12-1.818 4.54-4.47 4.54-2.394 0-3.784-1.248-4.49-2.754zm10.586-.323l2.438-1.42c.642 1.055 1.476 1.83 2.95 1.83 1.241 0 2.032-.625 2.032-1.486 0-1.033-.812-1.398-2.18-2l-.75-.324c-2.159-.925-3.592-2.087-3.592-4.54 0-2.26 1.711-3.982 4.384-3.982 1.903 0 3.272.667 4.255 2.41l-2.33 1.507c-.514-.925-1.07-1.291-1.925-1.291-.877 0-1.433.56-1.433 1.29 0 .905.556 1.27 1.84 1.83l.748.323C24.567 15.167 26 16.286 26 18.803 26 21.515 23.883 23 21.039 23c-2.78 0-4.576-1.334-5.453-3.078" fill="#000"/> +</svg> diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 8f87b3473b2e48b..b4b4e7866e9b7d6 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -66,6 +66,8 @@ exports[`Error SERVICE_AGENT_NAME 1`] = `"java"`; exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; +exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; + exports[`Error SERVICE_NAME 1`] = `"service name"`; exports[`Error SERVICE_NODE_NAME 1`] = `undefined`; @@ -176,6 +178,8 @@ exports[`Span SERVICE_AGENT_NAME 1`] = `"java"`; exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; +exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; + exports[`Span SERVICE_NAME 1`] = `"service name"`; exports[`Span SERVICE_NODE_NAME 1`] = `undefined`; @@ -286,6 +290,8 @@ exports[`Transaction SERVICE_AGENT_NAME 1`] = `"java"`; exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; +exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; + exports[`Transaction SERVICE_NAME 1`] = `"service name"`; exports[`Transaction SERVICE_NODE_NAME 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index ce2db4964a4120b..14233aad0f53c29 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -7,6 +7,7 @@ export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_AGENT_NAME = 'agent.name'; +export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; export const URL_FULL = 'url.full'; diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 548b29346e4839f..f4354baa97655d4 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -10,6 +10,7 @@ import { ILicense } from '../../licensing/public'; export interface ServiceConnectionNode { 'service.name': string; 'service.environment': string | null; + 'service.framework.name': string | null; 'agent.name': string; } export interface ExternalConnectionNode { diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 04e2a43a4b8f1d5..85d71784b55c708 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -16,7 +16,8 @@ import { getServicesProjection } from '../../../common/projections/services'; import { mergeProjection } from '../../../common/projections/util/merge_projection'; import { SERVICE_AGENT_NAME, - SERVICE_NAME + SERVICE_NAME, + SERVICE_FRAMEWORK_NAME } from '../../../common/elasticsearch_fieldnames'; export interface IEnvOptions { @@ -92,6 +93,11 @@ async function getServicesData(options: IEnvOptions) { terms: { field: SERVICE_AGENT_NAME } + }, + service_framework_name: { + terms: { + field: SERVICE_FRAMEWORK_NAME + } } } } @@ -109,7 +115,11 @@ async function getServicesData(options: IEnvOptions) { 'service.name': bucket.key as string, 'agent.name': (bucket.agent_name.buckets[0]?.key as string | undefined) || '', - 'service.environment': options.environment || null + 'service.environment': options.environment || null, + 'service.framework.name': + (bucket.service_framework_name.buckets[0]?.key as + | string + | undefined) || null }; }) || [] ); diff --git a/x-pack/plugins/apm/server/lib/services/map.ts b/x-pack/plugins/apm/server/lib/services/map.ts deleted file mode 100644 index 97bb925674e26af..000000000000000 --- a/x-pack/plugins/apm/server/lib/services/map.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import cytoscape from 'cytoscape'; -import { PromiseReturnType } from '../../../typings/common'; - -// This response right now just returns experimental data. -export type ServiceMapResponse = PromiseReturnType<typeof getServiceMap>; -export async function getServiceMap(): Promise<cytoscape.ElementDefinition[]> { - return [ - { data: { id: 'client', agentName: 'js-base' } }, - { data: { id: 'opbeans-node', agentName: 'nodejs' } }, - { data: { id: 'opbeans-python', agentName: 'python' } }, - { data: { id: 'opbeans-java', agentName: 'java' } }, - { data: { id: 'opbeans-ruby', agentName: 'ruby' } }, - { data: { id: 'opbeans-go', agentName: 'go' } }, - { data: { id: 'opbeans-go-2', agentName: 'go' } }, - { data: { id: 'opbeans-dotnet', agentName: 'dotnet' } }, - { data: { id: 'database', agentName: 'database' } }, - { data: { id: 'external API', agentName: 'external' } }, - - { - data: { - id: 'opbeans-client~opbeans-node', - source: 'client', - target: 'opbeans-node' - } - }, - { - data: { - id: 'opbeans-client~opbeans-python', - source: 'client', - target: 'opbeans-python' - } - }, - { - data: { - id: 'opbeans-python~opbeans-go', - source: 'opbeans-python', - target: 'opbeans-go' - } - }, - { - data: { - id: 'opbeans-python~opbeans-go-2', - source: 'opbeans-python', - target: 'opbeans-go-2' - } - }, - { - data: { - id: 'opbeans-python~opbeans-dotnet', - source: 'opbeans-python', - target: 'opbeans-dotnet' - } - }, - { - data: { - id: 'opbeans-node~opbeans-java', - source: 'opbeans-node', - target: 'opbeans-java' - } - }, - { - data: { - id: 'opbeans-node~database', - source: 'opbeans-node', - target: 'database' - } - }, - { - data: { - id: 'opbeans-go-2~opbeans-ruby', - source: 'opbeans-go-2', - target: 'opbeans-ruby' - } - }, - { - data: { - id: 'opbeans-go-2~external API', - source: 'opbeans-go-2', - target: 'external API' - } - } - ]; -} From ffab68d01bd4cf1abee7a8278329f30dada065c4 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Mon, 2 Mar 2020 19:32:04 -0500 Subject: [PATCH 05/22] [Endpoint] Alert Details Overview (#58412) --- x-pack/plugins/endpoint/common/types.ts | 144 ++++++++++++++--- .../endpoint/store/alerts/action.ts | 9 +- .../store/alerts/alert_details.test.ts | 65 ++++++++ .../endpoint/store/alerts/middleware.ts | 11 +- .../store/alerts/mock_alert_result_list.ts | 153 ++++++++++++++++-- .../endpoint/store/alerts/reducer.ts | 6 + .../endpoint/store/alerts/selectors.ts | 25 ++- .../public/applications/endpoint/types.ts | 11 +- .../endpoint/view/alerts/details/index.ts | 7 + .../details/metadata/file_accordion.tsx | 80 +++++++++ .../details/metadata/general_accordion.tsx | 68 ++++++++ .../details/metadata/hash_accordion.tsx | 49 ++++++ .../details/metadata/host_accordion.tsx | 55 +++++++ .../view/alerts/details/metadata/index.ts | 12 ++ .../metadata/source_process_accordion.tsx | 97 +++++++++++ .../source_process_token_accordion.tsx | 45 ++++++ .../view/alerts/details/overview/index.tsx | 94 +++++++++++ .../details/overview/metadata_panel.tsx | 40 +++++ .../endpoint/view/alerts/formatted_date.tsx | 22 +++ .../endpoint/view/alerts/index.test.tsx | 3 - .../endpoint/view/alerts/index.tsx | 97 ++++++----- .../endpoint/view/alerts/resolver.tsx | 1 + 22 files changed, 986 insertions(+), 108 deletions(-) create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/index.ts create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/file_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/general_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/hash_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/host_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/index.ts create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_token_accordion.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/metadata_panel.tsx create mode 100644 x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/formatted_date.tsx diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index 6d904fda6f74785..d804350a9002d4b 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -96,6 +96,59 @@ export interface EndpointResultList { request_page_index: number; } +export interface OSFields { + full: string; + name: string; + version: string; + variant: string; +} +export interface HostFields { + id: string; + hostname: string; + ip: string[]; + mac: string[]; + os: OSFields; +} +export interface HashFields { + md5: string; + sha1: string; + sha256: string; +} +export interface MalwareClassifierFields { + identifier: string; + score: number; + threshold: number; + version: string; +} +export interface PrivilegesFields { + description: string; + name: string; + enabled: boolean; +} +export interface ThreadFields { + id: number; + service_name: string; + start: number; + start_address: number; + start_address_module: string; +} +export interface DllFields { + pe: { + architecture: string; + imphash: string; + }; + code_signature: { + subject_name: string; + trusted: boolean; + }; + compile_time: number; + hash: HashFields; + malware_classifier: MalwareClassifierFields; + mapped_address: number; + mapped_size: number; + path: string; +} + /** * Describes an Alert Event. * Should be in line with ECS schema. @@ -109,26 +162,78 @@ export type AlertEvent = Immutable<{ event: { id: string; action: string; + category: string; + kind: string; + dataset: string; + module: string; + type: string; }; - file_classification: { - malware_classification: { - score: number; + process: { + code_signature: { + subject_name: string; + trusted: boolean; }; - }; - process?: { - unique_pid: number; + command_line: string; + domain: string; pid: number; + ppid: number; + entity_id: string; + parent: { + pid: number; + entity_id: string; + }; + name: string; + hash: HashFields; + pe: { + imphash: string; + }; + executable: string; + sid: string; + start: number; + malware_classifier: MalwareClassifierFields; + token: { + domain: string; + type: string; + user: string; + sid: string; + integrity_level: number; + integrity_level_name: string; + privileges: PrivilegesFields[]; + }; + thread: ThreadFields[]; + uptime: number; + user: string; }; - host: { - hostname: string; - ip: string; - os: { - name: string; + file: { + owner: string; + name: string; + path: string; + accessed: number; + mtime: number; + created: number; + size: number; + hash: HashFields; + pe: { + imphash: string; + }; + code_signature: { + trusted: boolean; + subject_name: string; }; + malware_classifier: { + features: { + data: { + buffer: string; + decompressed_size: number; + encoding: string; + }; + }; + } & MalwareClassifierFields; + temp_file_path: string; }; + host: HostFields; thread: {}; - endpoint?: {}; - endgame?: {}; + dll: DllFields[]; }>; /** @@ -161,18 +266,7 @@ export interface EndpointMetadata { id: string; name: string; }; - host: { - id: string; - hostname: string; - ip: string[]; - mac: string[]; - os: { - name: string; - full: string; - version: string; - variant: string; - }; - }; + host: HostFields; } /** diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts index a628a95003a7fe5..6c6310a7349ed26 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/action.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Immutable } from '../../../../../common/types'; +import { Immutable, AlertData } from '../../../../../common/types'; import { AlertListData } from '../../types'; interface ServerReturnedAlertsData { @@ -12,4 +12,9 @@ interface ServerReturnedAlertsData { readonly payload: Immutable<AlertListData>; } -export type AlertAction = ServerReturnedAlertsData; +interface ServerReturnedAlertDetailsData { + readonly type: 'serverReturnedAlertDetailsData'; + readonly payload: Immutable<AlertData>; +} + +export type AlertAction = ServerReturnedAlertsData | ServerReturnedAlertDetailsData; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts new file mode 100644 index 000000000000000..4edc31831eb14a9 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/alert_details.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Store, createStore, applyMiddleware } from 'redux'; +import { History } from 'history'; +import { alertListReducer } from './reducer'; +import { AlertListState } from '../../types'; +import { alertMiddlewareFactory } from './middleware'; +import { AppAction } from '../action'; +import { coreMock } from 'src/core/public/mocks'; +import { createBrowserHistory } from 'history'; + +describe('alert details tests', () => { + let store: Store<AlertListState, AppAction>; + let coreStart: ReturnType<typeof coreMock.createStart>; + let history: History<never>; + /** + * A function that waits until a selector returns true. + */ + let selectorIsTrue: (selector: (state: AlertListState) => boolean) => Promise<void>; + beforeEach(() => { + coreStart = coreMock.createStart(); + history = createBrowserHistory(); + const middleware = alertMiddlewareFactory(coreStart); + store = createStore(alertListReducer, applyMiddleware(middleware)); + + selectorIsTrue = async selector => { + // If the selector returns true, we're done + while (selector(store.getState()) !== true) { + // otherwise, wait til the next state change occurs + await new Promise(resolve => { + const unsubscribe = store.subscribe(() => { + unsubscribe(); + resolve(); + }); + }); + } + }; + }); + describe('when the user is on the alert list page with a selected alert in the url', () => { + beforeEach(() => { + const firstResponse: Promise<unknown> = Promise.resolve(1); + const secondResponse: Promise<unknown> = Promise.resolve(2); + coreStart.http.get.mockReturnValueOnce(firstResponse).mockReturnValueOnce(secondResponse); + + // Simulates user navigating to the /alerts page + store.dispatch({ + type: 'userChangedUrl', + payload: { + ...history.location, + pathname: '/alerts', + search: '?selected_alert=q9ncfh4q9ctrmc90umcq4', + }, + }); + }); + + it('should return alert details data', async () => { + // wait for alertDetails to be defined + await selectorIsTrue(state => state.alertDetails !== undefined); + }); + }); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts index 76a6867418bd865..2cb381e901b4ed2 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/middleware.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertResultList } from '../../../../../common/types'; +import { AlertResultList, AlertData } from '../../../../../common/types'; import { AppAction } from '../action'; import { MiddlewareFactory, AlertListState } from '../../types'; -import { isOnAlertPage, apiQueryParams } from './selectors'; +import { isOnAlertPage, apiQueryParams, hasSelectedAlert, uiQueryParams } from './selectors'; export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = coreStart => { return api => next => async (action: AppAction) => { @@ -19,5 +19,12 @@ export const alertMiddlewareFactory: MiddlewareFactory<AlertListState> = coreSta }); api.dispatch({ type: 'serverReturnedAlertsData', payload: response }); } + if (action.type === 'userChangedUrl' && isOnAlertPage(state) && hasSelectedAlert(state)) { + const uiParams = uiQueryParams(state); + const response: AlertData = await coreStart.http.get( + `/api/endpoint/alerts/${uiParams.selected_alert}` + ); + api.dispatch({ type: 'serverReturnedAlertDetailsData', payload: response }); + } }; }; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts index 8eadb3e7fb3dfde..7db94fc9d4266f6 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/mock_alert_result_list.ts @@ -32,29 +32,152 @@ export const mockAlertResultList: (options?: { id: 'ced9c68e-b94a-4d66-bb4c-6106514f0a2f', version: '3.0.0', }, - event: { - id: '2f1c0928-3876-4e11-acbb-9199257c7b1c', - action: 'open', - }, - file_classification: { - malware_classification: { - score: 3, - }, - }, - process: { - pid: 107, - unique_pid: 1, - }, host: { + id: 'xrctvybuni', hostname: 'HD-c15-bc09190a', - ip: '10.179.244.14', + ip: ['10.179.244.14'], + mac: ['xsertcyvbunimkn56edtyf'], os: { - name: 'Windows', + full: 'Windows 10', + name: 'windows', + version: '10', + variant: '3', }, }, thread: {}, prev: null, next: null, + event: { + id: '2f1c0928-3876-4e11-acbb-9199257c7b1c', + action: 'creation', + category: 'malware', + dataset: 'endpoint', + kind: 'alert', + module: 'endpoint', + type: 'creation', + }, + file: { + accessed: 1542789400, + created: 1542789400, + hash: { + md5: '4ace3baaa509d08510405e1b169e325b', + sha1: '27fb21cf5db95ffca43b234affa99becc4023b9d', + sha256: '6ed1c836dbf099be7845bdab7671def2c157643761b52251e04e9b6ee109ec75', + }, + pe: { + imphash: '835d619dfdf3cc727cebd91300ab3462', + }, + mtime: 1542789400, + owner: 'Administrators', + name: 'test name', + path: 'C:\\Windows\\TEMP\\tmp0000008f\\tmp00001be5', + size: 188416, + code_signature: { + subject_name: 'Cybereason Inc', + trusted: false, + }, + malware_classifier: { + features: { + data: { + buffer: + 'eAHtnU1oHHUUwHsQ7MGDiIIUD4sH8WBBxJtopiLoUY0pYo2ZTbJJ0yQ17m4+ms/NRzeVWpuUWCL4sWlEYvFQ8KJQ6NCTEA8eRD30sIo3PdSriLi7837Pko3LbHZ2M5m+XObHm/d/X////83O7jCZvzacHBpPplNdfalkdjSdyty674Ft59dN71Dpb9v5eKh8LMEHjsCF2wIfVlRKsHROYPGkQO5+gY2vBSYYdWZFYGwEO/cITHMqkxPYnBBY+07gtCuQ9gSGigJ5lPPYGXcE+jA4z3Ad1ZtAUiDUyrEEPYzqRnIKgxd/Rgc7gygPo5wn95PouN7OeEYJ1UXiJgRmvscgp/LOziIkkSyT+xRVnXhZ4DKh5goCkzidRHkGO4uvCyw9LDDtCay8ILCAzrJOJaGuZwUuvSewivJVIPsklq8JbL4qMJsTSCcExrGs83WKU295ZFo5lr2TaZbcUw5FeJy8tgTeLpCy2iGeS67ABXzlgbEi1UC5FxcZnA4y/CLK82Qxi847FGGZRTLsCUxR1aWEwOp1AmOjDRYYzgwusL9WfqBiGJxnVAanixTq7Dp22LBdlWMJzlOx8wmBK2Rx5WmBLJIRwtAijOQE+ooCb2B5xBOYRtlfNeXpLpA7oyZRTqHzGenkmIJPnhBIMrzTwSA6H93CO5l+c1NA99f6IwLH8fUKdjTmDpTbgS50+gGVnECnE4PpooC2guPoaPADSHrcncNHmEHtAFkq3+EI+A37zsrrTvH3WTkvJLoOTyBp10wx2JcgVCRahA4NrICE4a+hrMXsA3qAHItW188E8ejO7XV3eh/KCYwxlamEwCgL8lN2wTntfrhY/U0g/5KAdvUpT+AszWqBdqH7VLeeZrExK9Cv1UgIDKA8g/cx7QAEP+AhAfRaMKB2HOJh+BSFSqKjSytNGBlc6PrpxvK7lCVDxbSG3Z7AhCMwx6gelwgLAltXBXJUTH29j+U1LHdipx/QprfKfGnF0sBpdBYxmEQyTzW0h6/0khcuhhJYRufym+i4VKMocJMs/KvfoW3/UJb4PeZOSZVONThZz4djP/75TAXa/CVfOvX3RgVLIDreLPN1pP1osW7lGmHsEhjBOzf+EPBE4vndvWz5xb/cChxGcv1LAb+tluALKnZ47isf1MXvz1ZMlsCXbXtPceqhrcp1ps6YHwQeBXLEPCf7q23tl9uJui0bGBgYRAccv7uXr/g5Af+2oNTrpgTa/vnpjBvpLAwM4gRBPvIZGBgYGBgYGBgYGBgYGBgYGBgYGBgYNAOc9oMXs4GBgYFBcNBnww5QzDXgRtPSaZ5lg/itsRaslgZ3bnWEEVnhMetIBwiiVnlbCbWrEftrt11zdwWnseFW1QO63w1is3ptD1pV9xG0t+zvfUrzrvh380qwXWAVCw6h78GIfG7ZlzltXu6hd+y92fECRFhjuH3bXG8N43oXEHperdzvUbteaDxhVTUeq25fqhG1X6Ai8mtF6BDXz2wR+dzSgg4Qsxls5T11XMG+82y8GkG+b7kL69xg7mF1SFvhBgYGsYH/Xi7HE+PVkiB2jt1bNZxT+k4558jR53ydz5//1m1KOgYGBgYGBgYGEQfnsYaG2z1sdPJS79XQSu91ndobOAHCaN5vNzUk1bceQVzUpbw3iOuT+UFmR18bHrp3gyhDC56lCd1y85w2+HSNUwVhhdGC7blLf+bV/fqtvhMg1NDjCcugB1QXswbs8ekj/v1BgzFHBIIsyP+HfwFdMpzu', + decompressed_size: 27831, + encoding: 'zlib', + }, + }, + identifier: 'endpointpe', + score: 1, + threshold: 0.66, + version: '3.0.33', + }, + temp_file_path: 'C:\\Windows\\TEMP\\1bb9abfc-ca14-47b2-9f2c-10c323df42f9', + }, + process: { + pid: 1076, + ppid: 432, + entity_id: 'wertqwer', + parent: { + pid: 432, + entity_id: 'adsfsdaf', + }, + name: 'test name', + code_signature: { + subject_name: 'Cybereason Inc', + trusted: true, + }, + command_line: '"C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe"', + domain: 'NT AUTHORITY', + executable: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe', + hash: { + md5: '1f2d082566b0fc5f2c238a5180db7451', + sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d', + sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2', + }, + pe: { + imphash: 'c30d230b81c734e82e86e2e2fe01cd01', + }, + malware_classifier: { + identifier: 'Whitelisted', + score: 0, + threshold: 0, + version: '3.0.0', + }, + thread: [ + { + id: 1652, + service_name: 'CybereasonAntiMalware', + start: 1542788400, + start_address: 8791698721056, + start_address_module: 'C:\\Program Files\\Cybereason ActiveProbe\\gzfltum.dll', + }, + ], + sid: 'S-1-5-18', + start: 1542788400, + token: { + domain: 'NT AUTHORITY', + integrity_level: 16384, + integrity_level_name: 'system', + privileges: [ + { + description: 'Replace a process level token', + enabled: false, + name: 'SeAssignPrimaryTokenPrivilege', + }, + ], + sid: 'S-1-5-18', + type: 'tokenPrimary', + user: 'SYSTEM', + }, + uptime: 1025, + user: 'SYSTEM', + }, + dll: [ + { + pe: { + architecture: 'x64', + imphash: 'c30d230b81c734e82e86e2e2fe01cd01', + }, + code_signature: { + subject_name: 'Cybereason Inc', + trusted: true, + }, + compile_time: 1534424710, + hash: { + md5: '1f2d082566b0fc5f2c238a5180db7451', + sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d', + sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2', + }, + malware_classifier: { + identifier: 'Whitelisted', + score: 0, + threshold: 0, + version: '3.0.0', + }, + mapped_address: 5362483200, + mapped_size: 0, + path: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe', + }, + ], }); } const mock: AlertResultList = { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts index 77d7397d7258194..ee172fa80f1fed7 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/reducer.ts @@ -11,6 +11,7 @@ import { AppAction } from '../action'; const initialState = (): AlertListState => { return { alerts: [], + alertDetails: undefined, pageSize: 10, pageIndex: 0, total: 0, @@ -43,6 +44,11 @@ export const alertListReducer: Reducer<AlertListState, AppAction> = ( ...state, location: action.payload, }; + } else if (action.type === 'serverReturnedAlertDetailsData') { + return { + ...state, + alertDetails: action.payload, + }; } return state; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts index f217e3cda91913d..7ce7c2d08691eea 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/store/alerts/selectors.ts @@ -15,7 +15,7 @@ import { AlertsAPIQueryParams, CreateStructuredSelector, } from '../../types'; -import { Immutable, LegacyEndpointEvent } from '../../../../../common/types'; +import { Immutable } from '../../../../../common/types'; const createStructuredSelector: CreateStructuredSelector = createStructuredSelectorWithBadType; /** @@ -23,6 +23,8 @@ const createStructuredSelector: CreateStructuredSelector = createStructuredSelec */ export const alertListData = (state: AlertListState) => state.alerts; +export const selectedAlertDetailsData = (state: AlertListState) => state.alertDetails; + /** * Returns the alert list pagination data from state */ @@ -96,20 +98,11 @@ export const hasSelectedAlert: (state: AlertListState) => boolean = createSelect /** * Determine if the alert event is most likely compatible with LegacyEndpointEvent. */ -function isAlertEventLegacyEndpointEvent(event: { endgame?: {} }): event is LegacyEndpointEvent { - return event.endgame !== undefined && 'unique_pid' in event.endgame; -} - -export const selectedEvent: ( +export const selectedAlertIsLegacyEndpointEvent: ( state: AlertListState -) => LegacyEndpointEvent | undefined = createSelector( - uiQueryParams, - alertListData, - ({ selected_alert: selectedAlert }, alertList) => { - const found = alertList.find(alert => alert.event.id === selectedAlert); - if (!found) { - return found; - } - return isAlertEventLegacyEndpointEvent(found) ? found : undefined; +) => boolean = createSelector(selectedAlertDetailsData, function(event) { + if (event === undefined) { + return false; } -); + return 'endgame' in event; +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts index 6498462a8fc060a..b46785d3190e584 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/types.ts +++ b/x-pack/plugins/endpoint/public/applications/endpoint/types.ts @@ -93,19 +93,22 @@ export type AlertListData = AlertResultList; export interface AlertListState { /** Array of alert items. */ - alerts: ImmutableArray<AlertData>; + readonly alerts: ImmutableArray<AlertData>; /** The total number of alerts on the page. */ - total: number; + readonly total: number; /** Number of alerts per page. */ - pageSize: number; + readonly pageSize: number; /** Page number, starting at 0. */ - pageIndex: number; + readonly pageIndex: number; /** Current location object from React Router history. */ readonly location?: Immutable<EndpointAppLocation>; + + /** Specific Alert data to be shown in the details view */ + readonly alertDetails?: Immutable<AlertData>; } /** diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/index.ts new file mode 100644 index 000000000000000..1c7830947473797 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/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 { AlertDetailsOverview } from './overview'; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/file_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/file_accordion.tsx new file mode 100644 index 000000000000000..ac67e54f38779d1 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/file_accordion.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; +import { FormattedDate } from '../../formatted_date'; + +export const FileAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileName', { + defaultMessage: 'File Name', + }), + description: alertData.file.name, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.filePath', { + defaultMessage: 'File Path', + }), + description: alertData.file.path, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileSize', { + defaultMessage: 'File Size', + }), + description: alertData.file.size, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileCreated', { + defaultMessage: 'File Created', + }), + description: <FormattedDate timestamp={alertData.file.created} />, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileModified', { + defaultMessage: 'File Modified', + }), + description: <FormattedDate timestamp={alertData.file.mtime} />, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileAccessed', { + defaultMessage: 'File Accessed', + }), + description: <FormattedDate timestamp={alertData.file.accessed} />, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.signer', { + defaultMessage: 'Signer', + }), + description: alertData.file.code_signature.subject_name, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.owner', { + defaultMessage: 'Owner', + }), + description: alertData.file.owner, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsFileAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.file', + { + defaultMessage: 'File', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/general_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/general_accordion.tsx new file mode 100644 index 000000000000000..070c78c96858522 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/general_accordion.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; +import { FormattedDate } from '../../formatted_date'; + +export const GeneralAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.alertType', { + defaultMessage: 'Alert Type', + }), + description: alertData.event.category, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.eventType', { + defaultMessage: 'Event Type', + }), + description: alertData.event.kind, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.status', { + defaultMessage: 'Status', + }), + description: 'TODO', + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.dateCreated', { + defaultMessage: 'Date Created', + }), + description: <FormattedDate timestamp={alertData['@timestamp']} />, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.malwareScore', { + defaultMessage: 'MalwareScore', + }), + description: alertData.file.malware_classifier.score, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.fileName', { + defaultMessage: 'File Name', + }), + description: alertData.file.name, + }, + ]; + }, [alertData]); + return ( + <EuiAccordion + id="alertDetailsAlertAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.alert', + { + defaultMessage: 'Alert', + } + )} + paddingSize="l" + initialIsOpen={true} + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/hash_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/hash_accordion.tsx new file mode 100644 index 000000000000000..b2be083ce8f59d3 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/hash_accordion.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; + +export const HashAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.md5', { + defaultMessage: 'MD5', + }), + description: alertData.file.hash.md5, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha1', { + defaultMessage: 'SHA1', + }), + description: alertData.file.hash.sha1, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha256', { + defaultMessage: 'SHA256', + }), + description: alertData.file.hash.sha256, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsHashAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.hash', + { + defaultMessage: 'Hash', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/host_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/host_accordion.tsx new file mode 100644 index 000000000000000..4108781f0a79b01 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/host_accordion.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; + +export const HostAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.hostName', { + defaultMessage: 'Host Name', + }), + description: alertData.host.hostname, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.hostIP', { + defaultMessage: 'Host IP', + }), + description: alertData.host.ip.join(', '), + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.status', { + defaultMessage: 'Status', + }), + description: 'TODO', + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.os', { + defaultMessage: 'OS', + }), + description: alertData.host.os.name, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsHostAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.host', + { + defaultMessage: 'Host', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/index.ts b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/index.ts new file mode 100644 index 000000000000000..1eb755242d701f0 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { GeneralAccordion } from './general_accordion'; +export { HostAccordion } from './host_accordion'; +export { HashAccordion } from './hash_accordion'; +export { FileAccordion } from './file_accordion'; +export { SourceProcessAccordion } from './source_process_accordion'; +export { SourceProcessTokenAccordion } from './source_process_token_accordion'; diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_accordion.tsx new file mode 100644 index 000000000000000..4c961ad4b49640b --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_accordion.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; + +export const SourceProcessAccordion = memo(({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.processID', { + defaultMessage: 'Process ID', + }), + description: alertData.process.pid, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.processName', { + defaultMessage: 'Process Name', + }), + description: alertData.process.name, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.processPath', { + defaultMessage: 'Process Path', + }), + description: alertData.process.executable, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.md5', { + defaultMessage: 'MD5', + }), + description: alertData.process.hash.md5, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha1', { + defaultMessage: 'SHA1', + }), + description: alertData.process.hash.sha1, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sha256', { + defaultMessage: 'SHA256', + }), + description: alertData.process.hash.sha256, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.malwareScore', { + defaultMessage: 'MalwareScore', + }), + description: alertData.process.malware_classifier.score, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.parentProcessID', { + defaultMessage: 'Parent Process ID', + }), + description: alertData.process.parent.pid, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.signer', { + defaultMessage: 'Signer', + }), + description: alertData.process.code_signature.subject_name, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.username', { + defaultMessage: 'Username', + }), + description: alertData.process.token.user, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.domain', { + defaultMessage: 'Domain', + }), + description: alertData.process.token.domain, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsSourceProcessAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.sourceProcess', + { + defaultMessage: 'Source Process', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_token_accordion.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_token_accordion.tsx new file mode 100644 index 000000000000000..7d75d4478afb32b --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/metadata/source_process_token_accordion.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiAccordion, EuiDescriptionList } from '@elastic/eui'; +import { Immutable, AlertData } from '../../../../../../../common/types'; + +export const SourceProcessTokenAccordion = memo( + ({ alertData }: { alertData: Immutable<AlertData> }) => { + const columns = useMemo(() => { + return [ + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.sid', { + defaultMessage: 'SID', + }), + description: alertData.process.token.sid, + }, + { + title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.integrityLevel', { + defaultMessage: 'Integrity Level', + }), + description: alertData.process.token.integrity_level, + }, + ]; + }, [alertData]); + + return ( + <EuiAccordion + id="alertDetailsSourceProcessTokenAccordion" + buttonContent={i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.accordionTitles.sourceProcessToken', + { + defaultMessage: 'Source Process Token', + } + )} + paddingSize="l" + > + <EuiDescriptionList type="column" listItems={columns} /> + </EuiAccordion> + ); + } +); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx new file mode 100644 index 000000000000000..080c70ca43bae96 --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/index.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiTitle, EuiText, EuiHealth, EuiTabbedContent } from '@elastic/eui'; +import { useAlertListSelector } from '../../hooks/use_alerts_selector'; +import * as selectors from '../../../../store/alerts/selectors'; +import { MetadataPanel } from './metadata_panel'; +import { FormattedDate } from '../../formatted_date'; +import { AlertDetailResolver } from '../../resolver'; + +export const AlertDetailsOverview = memo(() => { + const alertDetailsData = useAlertListSelector(selectors.selectedAlertDetailsData); + if (alertDetailsData === undefined) { + return null; + } + const selectedAlertIsLegacyEndpointEvent = useAlertListSelector( + selectors.selectedAlertIsLegacyEndpointEvent + ); + + const tabs = useMemo(() => { + return [ + { + id: 'overviewMetadata', + name: i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.overview', + { + defaultMessage: 'Overview', + } + ), + content: ( + <> + <EuiSpacer /> + <MetadataPanel /> + </> + ), + }, + { + id: 'overviewResolver', + name: i18n.translate( + 'xpack.endpoint.application.endpoint.alertDetails.overview.tabs.resolver', + { + defaultMessage: 'Resolver', + } + ), + content: ( + <> + <EuiSpacer /> + {selectedAlertIsLegacyEndpointEvent && <AlertDetailResolver />} + </> + ), + }, + ]; + }, [selectedAlertIsLegacyEndpointEvent]); + + return ( + <> + <section className="details-overview-summary"> + <EuiTitle size="s"> + <h3> + <FormattedMessage + id="xpack.endpoint.application.endpoint.alertDetails.overview.title" + defaultMessage="Detected Malicious File" + /> + </h3> + </EuiTitle> + <EuiSpacer /> + <EuiText> + <p> + <FormattedMessage + id="xpack.endpoint.application.endpoint.alertDetails.overview.summary" + defaultMessage="MalwareScore detected the opening of a document on {hostname} on {date}" + values={{ + hostname: alertDetailsData.host.hostname, + date: <FormattedDate timestamp={alertDetailsData['@timestamp']} />, + }} + /> + </p> + </EuiText> + <EuiSpacer /> + <EuiText> + Endpoint Status: <EuiHealth color="success">Online</EuiHealth> + </EuiText> + <EuiText>Alert Status: Open</EuiText> + <EuiSpacer /> + </section> + <EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} /> + </> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/metadata_panel.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/metadata_panel.tsx new file mode 100644 index 000000000000000..556d7bea2e3100d --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/details/overview/metadata_panel.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useAlertListSelector } from '../../hooks/use_alerts_selector'; +import * as selectors from '../../../../store/alerts/selectors'; +import { + GeneralAccordion, + HostAccordion, + HashAccordion, + FileAccordion, + SourceProcessAccordion, + SourceProcessTokenAccordion, +} from '../metadata'; + +export const MetadataPanel = memo(() => { + const alertDetailsData = useAlertListSelector(selectors.selectedAlertDetailsData); + if (alertDetailsData === undefined) { + return null; + } + return ( + <section className="overview-metadata-panel"> + <GeneralAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <HostAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <HashAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <FileAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <SourceProcessAccordion alertData={alertDetailsData} /> + <EuiSpacer /> + <SourceProcessTokenAccordion alertData={alertDetailsData} /> + </section> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/formatted_date.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/formatted_date.tsx new file mode 100644 index 000000000000000..731bd31b26cefbf --- /dev/null +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/formatted_date.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo } from 'react'; +import { FormattedDate as ReactIntlFormattedDate } from '@kbn/i18n/react'; + +export const FormattedDate = memo(({ timestamp }: { timestamp: number }) => { + const date = new Date(timestamp); + return ( + <ReactIntlFormattedDate + value={date} + year="numeric" + month="2-digit" + day="2-digit" + hour="2-digit" + minute="2-digit" + second="2-digit" + /> + ); +}); diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx index fe362f21a178ea6..aae44824c3164c0 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.test.tsx @@ -140,9 +140,6 @@ describe('when on the alerting page', () => { it('should show the flyout', async () => { await render().findByTestId('alertDetailFlyout'); }); - it('should render resolver', async () => { - await render().findByTestId('alertResolver'); - }); describe('when the user clicks the close button on the flyout', () => { let renderResult: reactTestingLibrary.RenderResult; beforeEach(async () => { diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx index 3c229484ede4e02..5d405f8c6fbae7e 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/index.tsx @@ -17,15 +17,21 @@ import { EuiFlyoutBody, EuiTitle, EuiBadge, + EuiLoadingSpinner, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageContentBody, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useHistory, Link } from 'react-router-dom'; -import { FormattedDate } from 'react-intl'; +import { useHistory } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; import { urlFromQueryParams } from './url_from_query_params'; import { AlertData } from '../../../../../common/types'; import * as selectors from '../../store/alerts/selectors'; import { useAlertListSelector } from './hooks/use_alerts_selector'; -import { AlertDetailResolver } from './resolver'; +import { AlertDetailsOverview } from './details'; +import { FormattedDate } from './formatted_date'; export const AlertIndex = memo(() => { const history = useHistory(); @@ -87,7 +93,6 @@ export const AlertIndex = memo(() => { const alertListData = useAlertListSelector(selectors.alertListData); const hasSelectedAlert = useAlertListSelector(selectors.hasSelectedAlert); const queryParams = useAlertListSelector(selectors.uiQueryParams); - const selectedEvent = useAlertListSelector(selectors.selectedEvent); const onChangeItemsPerPage = useCallback( newPageSize => { @@ -119,10 +124,10 @@ export const AlertIndex = memo(() => { history.push(urlFromQueryParams(paramsWithoutSelectedAlert)); }, [history, queryParams]); - const datesForRows: Map<AlertData, Date> = useMemo(() => { + const timestampForRows: Map<AlertData, number> = useMemo(() => { return new Map( alertListData.map(alertData => { - return [alertData, new Date(alertData['@timestamp'])]; + return [alertData, alertData['@timestamp']]; }) ); }, [alertListData]); @@ -136,9 +141,11 @@ export const AlertIndex = memo(() => { const row = alertListData[rowIndex % pageSize]; if (columnId === 'alert_type') { return ( - <Link + <EuiLink data-testid="alertTypeCellLink" - to={urlFromQueryParams({ ...queryParams, selected_alert: row.event.id })} + onClick={() => + history.push(urlFromQueryParams({ ...queryParams, selected_alert: row.id })) + } > {i18n.translate( 'xpack.endpoint.application.endpoint.alerts.alertType.maliciousFileDescription', @@ -146,7 +153,7 @@ export const AlertIndex = memo(() => { defaultMessage: 'Malicious File', } )} - </Link> + </EuiLink> ); } else if (columnId === 'event_type') { return row.event.action; @@ -157,19 +164,9 @@ export const AlertIndex = memo(() => { } else if (columnId === 'host_name') { return row.host.hostname; } else if (columnId === 'timestamp') { - const date = datesForRows.get(row)!; - if (date && isFinite(date.getTime())) { - return ( - <FormattedDate - value={date} - year="numeric" - month="2-digit" - day="2-digit" - hour="2-digit" - minute="2-digit" - second="2-digit" - /> - ); + const timestamp = timestampForRows.get(row)!; + if (timestamp) { + return <FormattedDate timestamp={timestamp} />; } else { return ( <EuiBadge color="warning"> @@ -185,11 +182,11 @@ export const AlertIndex = memo(() => { } else if (columnId === 'archived') { return null; } else if (columnId === 'malware_score') { - return row.file_classification.malware_classification.score; + return row.file.malware_classifier.score; } return null; }; - }, [alertListData, datesForRows, pageSize, queryParams, total]); + }, [total, alertListData, pageSize, history, queryParams, timestampForRows]); const pagination = useMemo(() => { return { @@ -201,6 +198,16 @@ export const AlertIndex = memo(() => { }; }, [onChangeItemsPerPage, onChangePage, pageIndex, pageSize]); + const columnVisibility = useMemo( + () => ({ + visibleColumns, + setVisibleColumns, + }), + [setVisibleColumns, visibleColumns] + ); + + const selectedAlertData = useAlertListSelector(selectors.selectedAlertDetailsData); + return ( <> {hasSelectedAlert && ( @@ -215,29 +222,37 @@ export const AlertIndex = memo(() => { </EuiTitle> </EuiFlyoutHeader> <EuiFlyoutBody> - <AlertDetailResolver selectedEvent={selectedEvent} /> + {selectedAlertData ? <AlertDetailsOverview /> : <EuiLoadingSpinner size="xl" />} </EuiFlyoutBody> </EuiFlyout> )} <EuiPage data-test-subj="alertListPage" data-testid="alertListPage"> <EuiPageBody> <EuiPageContent> - <EuiDataGrid - aria-label="Alert List" - rowCount={total} - columns={columns} - columnVisibility={useMemo( - () => ({ - visibleColumns, - setVisibleColumns, - }), - [setVisibleColumns, visibleColumns] - )} - renderCellValue={renderCellValue} - pagination={pagination} - data-test-subj="alertListGrid" - data-testid="alertListGrid" - /> + <EuiPageContentHeader> + <EuiPageContentHeaderSection> + <EuiTitle size="l"> + <h1> + <FormattedMessage + id="xpack.endpoint.alertList.viewTitle" + defaultMessage="Alerts" + /> + </h1> + </EuiTitle> + </EuiPageContentHeaderSection> + </EuiPageContentHeader> + <EuiPageContentBody> + <EuiDataGrid + aria-label="Alert List" + rowCount={total} + columns={columns} + columnVisibility={columnVisibility} + renderCellValue={renderCellValue} + pagination={pagination} + data-test-subj="alertListGrid" + data-testid="alertListGrid" + /> + </EuiPageContentBody> </EuiPageContent> </EuiPageBody> </EuiPage> diff --git a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx index c7ef7f73dfe05b6..ec1dab45d50aba5 100644 --- a/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx +++ b/x-pack/plugins/endpoint/public/applications/endpoint/view/alerts/resolver.tsx @@ -18,6 +18,7 @@ export const AlertDetailResolver = styled( ({ className, selectedEvent }: { className?: string; selectedEvent?: LegacyEndpointEvent }) => { const context = useKibana<EndpointPluginServices>(); const { store } = storeFactory(context); + return ( <div className={className} data-test-subj="alertResolver" data-testid="alertResolver"> <Provider store={store}> From 41deda35841fc609a58aba5fc87a477e589b522c Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Tue, 3 Mar 2020 10:28:40 +0300 Subject: [PATCH 06/22] Fix monaco editor styling (#58888) * Fix monaco editor styling * Change line highlight border --- src/plugins/kibana_react/public/code_editor/editor_theme.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/plugins/kibana_react/public/code_editor/editor_theme.ts b/src/plugins/kibana_react/public/code_editor/editor_theme.ts index 6e30135686797ca..586b4a568c348a1 100644 --- a/src/plugins/kibana_react/public/code_editor/editor_theme.ts +++ b/src/plugins/kibana_react/public/code_editor/editor_theme.ts @@ -101,6 +101,11 @@ export function createTheme( 'editor.selectionBackground': selectionBackgroundColor, 'editorWidget.border': euiTheme.euiColorLightShade, 'editorWidget.background': euiTheme.euiColorLightestShade, + 'editorCursor.foreground': euiTheme.euiColorDarkestShade, + 'editorSuggestWidget.selectedBackground': euiTheme.euiColorLightShade, + 'list.hoverBackground': euiTheme.euiColorLightShade, + 'list.highlightForeground': euiTheme.euiColorPrimary, + 'editor.lineHighlightBorder': euiTheme.euiColorLightestShade, }, }; } From 421d9d502b45b04569c48d0c42e48872fdaffc98 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Tue, 3 Mar 2020 10:31:02 +0300 Subject: [PATCH 07/22] [Vis Editor] Fix field combo box search value (#58601) * Fix field combo box search value * Fix inconsistent behavior * Apply validation for agg_select --- .../vis_default_editor/public/components/agg_select.tsx | 7 +++++-- .../public/components/controls/field.tsx | 8 ++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx index a2cec61b122efa5..9a408c2d98b2217 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/agg_select.tsx @@ -17,7 +17,7 @@ * under the License. */ import { get, has } from 'lodash'; -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow, EuiLink, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -52,6 +52,7 @@ function DefaultEditorAggSelect({ isSubAggregation, onChangeAggType, }: DefaultEditorAggSelectProps) { + const [isDirty, setIsDirty] = useState(false); const { services } = useKibana(); const selectedOptions: ComboBoxGroupedOptions<IAggType> = value ? [{ label: value.title, target: value }] @@ -100,7 +101,7 @@ function DefaultEditorAggSelect({ ); } - const isValid = !!value && !errors.length; + const isValid = !!value && !errors.length && !isDirty; const onChange = useCallback( (options: EuiComboBoxOptionProps[]) => { @@ -111,6 +112,7 @@ function DefaultEditorAggSelect({ }, [setValue] ); + const onSearchChange = useCallback(searchValue => setIsDirty(Boolean(searchValue)), []); const setTouched = useCallback( () => onChangeAggType({ type: AGG_TYPE_ACTION_KEYS.TOUCHED, payload: true }), @@ -151,6 +153,7 @@ function DefaultEditorAggSelect({ singleSelection={{ asPlainText: true }} onBlur={setTouched} onChange={onChange} + onSearchChange={onSearchChange} data-test-subj="defaultEditorAggSelect" isClearable={false} isInvalid={showValidation ? !isValid : false} diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx index 8bf7bc384b07a06..d605fb203f4d3ca 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/controls/field.tsx @@ -18,7 +18,7 @@ */ import { get } from 'lodash'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -50,6 +50,7 @@ function FieldParamEditor({ setValidity, setValue, }: FieldParamEditorProps) { + const [isDirty, setIsDirty] = useState(false); const selectedOptions: ComboBoxGroupedOptions<IndexPatternField> = value ? [{ label: value.displayName || value.name, target: value }] : []; @@ -79,7 +80,7 @@ function FieldParamEditor({ ); } - const isValid = !!value && !errors.length; + const isValid = !!value && !errors.length && !isDirty; useValidation(setValidity, isValid); @@ -98,6 +99,8 @@ function FieldParamEditor({ } }, []); + const onSearchChange = useCallback(searchValue => setIsDirty(Boolean(searchValue)), []); + return ( <EuiFormRow label={customLabel || label} @@ -119,6 +122,7 @@ function FieldParamEditor({ isInvalid={showValidation ? !isValid : false} onChange={onChange} onBlur={setTouched} + onSearchChange={onSearchChange} data-test-subj="visDefaultEditorField" fullWidth={true} /> From ecbcceb74d8e37d069b6dafc2136908f664b7bb6 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger <walter@elastic.co> Date: Tue, 3 Mar 2020 08:59:00 +0100 Subject: [PATCH 08/22] [ML] Transform: Fix advanced editor initialization. (#59006) Fixes regression introduced by #58015 to correctly initialize the transform wizard advanced editor with the current configuration. --- .../step_define/step_define_form.tsx | 5 ++++ .../apps/transform/creation_index_pattern.ts | 29 +++++++++++++++++++ .../services/transform_ui/wizard.ts | 14 +++++++++ 3 files changed, 48 insertions(+) diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx index 3adb74e4704dcdf..bde832894632c06 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/step_define_form.tsx @@ -404,6 +404,10 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange xJson: advancedEditorConfig, } = useXJsonMode(stringifiedPivotConfig); + useEffect(() => { + setAdvancedEditorConfig(stringifiedPivotConfig); + }, [setAdvancedEditorConfig, stringifiedPivotConfig]); + // source config const stringifiedSourceConfig = JSON.stringify(previewRequest.source.query, null, 2); const [ @@ -797,6 +801,7 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange > <EuiPanel grow={false} paddingSize="none"> <EuiCodeEditor + data-test-subj="transformAdvancedPivotEditor" mode={xJsonMode} width="100%" value={advancedEditorConfig} diff --git a/x-pack/test/functional/apps/transform/creation_index_pattern.ts b/x-pack/test/functional/apps/transform/creation_index_pattern.ts index 6e35b0c1a81cadc..5b54bfdafdbdbc3 100644 --- a/x-pack/test/functional/apps/transform/creation_index_pattern.ts +++ b/x-pack/test/functional/apps/transform/creation_index_pattern.ts @@ -57,6 +57,28 @@ export default function({ getService }: FtrProviderContext) { return `user-${this.transformId}`; }, expected: { + pivotAdvancedEditorValue: { + group_by: { + 'category.keyword': { + terms: { + field: 'category.keyword', + }, + }, + order_date: { + date_histogram: { + field: 'order_date', + calendar_interval: '1m', + }, + }, + }, + aggregations: { + 'products.base_price.avg': { + avg: { + field: 'products.base_price', + }, + }, + }, + }, pivotPreview: { column: 0, values: [`Men's Accessories`], @@ -152,6 +174,13 @@ export default function({ getService }: FtrProviderContext) { await transform.wizard.assertAdvancedPivotEditorSwitchCheckState(false); }); + it('displays the advanced configuration', async () => { + await transform.wizard.enabledAdvancedPivotEditor(); + await transform.wizard.assertAdvancedPivotEditorContent( + testData.expected.pivotAdvancedEditorValue + ); + }); + it('loads the pivot preview', async () => { await transform.wizard.assertPivotPreviewLoaded(); }); diff --git a/x-pack/test/functional/services/transform_ui/wizard.ts b/x-pack/test/functional/services/transform_ui/wizard.ts index e823117ad701675..aca08f7083aa856 100644 --- a/x-pack/test/functional/services/transform_ui/wizard.ts +++ b/x-pack/test/functional/services/transform_ui/wizard.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export function TransformWizardProvider({ getService }: FtrProviderContext) { + const aceEditor = getService('aceEditor'); const testSubjects = getService('testSubjects'); const comboBox = getService('comboBox'); const retry = getService('retry'); @@ -273,6 +274,12 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { await this.assertAggregationEntryExists(index, expectedLabel); }, + async assertAdvancedPivotEditorContent(expectedValue: Record<string, any>) { + const advancedEditorString = await aceEditor.getValue('transformAdvancedPivotEditor'); + const advancedEditorValue = JSON.parse(advancedEditorString); + expect(advancedEditorValue).to.eql(expectedValue); + }, + async assertAdvancedPivotEditorSwitchExists() { await testSubjects.existOrFail(`transformAdvancedPivotEditorSwitch`, { allowHidden: true }); }, @@ -287,6 +294,13 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) { ); }, + async enabledAdvancedPivotEditor() { + await this.assertAdvancedPivotEditorSwitchCheckState(false); + await testSubjects.click('transformAdvancedPivotEditorSwitch'); + await this.assertAdvancedPivotEditorSwitchCheckState(true); + await testSubjects.existOrFail('transformAdvancedPivotEditor'); + }, + async assertTransformIdInputExists() { await testSubjects.existOrFail('transformIdInput'); }, From bb55e8a21cc50cda8cf8753064b49f433a796451 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger <walter@elastic.co> Date: Tue, 3 Mar 2020 09:01:45 +0100 Subject: [PATCH 09/22] [ML] Transforms: Migrate server plugin to NP. (#58714) Migrate transform legacy server to NP. - Create server plugin/index for transform in x-pack/plugins. - Move all legacy/server files to plugins/transform --- x-pack/.i18nrc.json | 2 +- x-pack/legacy/plugins/transform/index.ts | 19 +- .../legacy/plugins/transform/server/plugin.ts | 17 - .../transform/server/routes/api/app.ts | 99 ----- .../server/routes/api/register_routes.ts | 14 - .../routes/api/transform_audit_messages.ts | 84 ---- .../transform/server/routes/api/transforms.ts | 261 ------------- .../legacy/plugins/transform/server/shim.ts | 46 --- x-pack/plugins/transform/kibana.json | 15 + .../server/client/elasticsearch_transform.ts | 0 x-pack/plugins/transform/server/index.ts | 11 + x-pack/plugins/transform/server/plugin.ts | 89 +++++ .../server/routes/api/error_utils.ts | 16 +- .../transform/server/routes/api/privileges.ts | 85 +++++ .../transform/server/routes/api/schema.ts | 16 + .../transform/server/routes/api/transforms.ts | 360 ++++++++++++++++++ .../routes/api/transforms_audit_messages.ts | 91 +++++ .../plugins/transform/server/routes/index.ts | 24 ++ .../transform/server/services/index.ts | 7 + .../transform/server/services/license.ts | 91 +++++ x-pack/plugins/transform/server/types.ts | 18 + 21 files changed, 824 insertions(+), 541 deletions(-) delete mode 100644 x-pack/legacy/plugins/transform/server/plugin.ts delete mode 100644 x-pack/legacy/plugins/transform/server/routes/api/app.ts delete mode 100644 x-pack/legacy/plugins/transform/server/routes/api/register_routes.ts delete mode 100644 x-pack/legacy/plugins/transform/server/routes/api/transform_audit_messages.ts delete mode 100644 x-pack/legacy/plugins/transform/server/routes/api/transforms.ts delete mode 100644 x-pack/legacy/plugins/transform/server/shim.ts create mode 100644 x-pack/plugins/transform/kibana.json rename x-pack/{legacy => }/plugins/transform/server/client/elasticsearch_transform.ts (100%) create mode 100644 x-pack/plugins/transform/server/index.ts create mode 100644 x-pack/plugins/transform/server/plugin.ts rename x-pack/{legacy => }/plugins/transform/server/routes/api/error_utils.ts (80%) create mode 100644 x-pack/plugins/transform/server/routes/api/privileges.ts create mode 100644 x-pack/plugins/transform/server/routes/api/schema.ts create mode 100644 x-pack/plugins/transform/server/routes/api/transforms.ts create mode 100644 x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts create mode 100644 x-pack/plugins/transform/server/routes/index.ts create mode 100644 x-pack/plugins/transform/server/services/index.ts create mode 100644 x-pack/plugins/transform/server/services/license.ts create mode 100644 x-pack/plugins/transform/server/types.ts diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 51099815ec938f2..8f5a5ea4f10e40e 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -39,7 +39,7 @@ "xpack.snapshotRestore": "legacy/plugins/snapshot_restore", "xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"], "xpack.taskManager": "legacy/plugins/task_manager", - "xpack.transform": "legacy/plugins/transform", + "xpack.transform": ["legacy/plugins/transform", "plugins/transform"], "xpack.triggersActionsUI": "plugins/triggers_actions_ui", "xpack.upgradeAssistant": "plugins/upgrade_assistant", "xpack.uptime": "legacy/plugins/uptime", diff --git a/x-pack/legacy/plugins/transform/index.ts b/x-pack/legacy/plugins/transform/index.ts index d0799f46cbd25d6..10f4732152c43a0 100644 --- a/x-pack/legacy/plugins/transform/index.ts +++ b/x-pack/legacy/plugins/transform/index.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; import { resolve } from 'path'; + import { PLUGIN } from './common/constants'; -import { Plugin as TransformPlugin } from './server/plugin'; -import { createServerShim } from './server/shim'; export function transform(kibana: any) { return new kibana.Plugin({ @@ -20,20 +18,5 @@ export function transform(kibana: any) { styleSheetPaths: resolve(__dirname, 'public/app/index.scss'), managementSections: ['plugins/transform'], }, - init(server: Legacy.Server) { - const { core, plugins } = createServerShim(server, PLUGIN.ID); - const transformPlugin = new TransformPlugin(); - - // Start plugin - transformPlugin.start(core, plugins); - - // Register license checker - plugins.license.registerLicenseChecker( - server, - PLUGIN.ID, - PLUGIN.getI18nName(), - PLUGIN.MINIMUM_LICENSE_REQUIRED - ); - }, }); } diff --git a/x-pack/legacy/plugins/transform/server/plugin.ts b/x-pack/legacy/plugins/transform/server/plugin.ts deleted file mode 100644 index f9264ee1f25077f..000000000000000 --- a/x-pack/legacy/plugins/transform/server/plugin.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { API_BASE_PATH } from '../common/constants'; -import { registerRoutes } from './routes/api/register_routes'; -import { Core, Plugins } from './shim'; - -export class Plugin { - public start(core: Core, plugins: Plugins): void { - const router = core.http.createRouter(API_BASE_PATH); - - // Register routes - registerRoutes(router, plugins); - } -} diff --git a/x-pack/legacy/plugins/transform/server/routes/api/app.ts b/x-pack/legacy/plugins/transform/server/routes/api/app.ts deleted file mode 100644 index c3189794b6eb0d2..000000000000000 --- a/x-pack/legacy/plugins/transform/server/routes/api/app.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router'; -import { wrapCustomError } from '../../../../../server/lib/create_router/error_wrappers'; -import { APP_CLUSTER_PRIVILEGES, APP_INDEX_PRIVILEGES } from '../../../common/constants'; -// NOTE: now we import it from our "public" folder, but when the Authorisation lib -// will move to the "es_ui_shared" plugin, it will be imported from its "static" folder -import { Privileges } from '../../../public/app/lib/authorization'; -import { Plugins } from '../../shim'; - -let xpackMainPlugin: any; - -export function registerAppRoutes(router: Router, plugins: Plugins) { - xpackMainPlugin = plugins.xpack_main; - router.get('privileges', getPrivilegesHandler); -} - -export function getXpackMainPlugin() { - return xpackMainPlugin; -} - -const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = {}): string[] => - Object.keys(privilegesObject).reduce((privileges: string[], privilegeName: string): string[] => { - if (!privilegesObject[privilegeName]) { - privileges.push(privilegeName); - } - return privileges; - }, []); - -export const getPrivilegesHandler: RouterRouteHandler = async ( - req, - callWithRequest -): Promise<Privileges> => { - const xpackInfo = getXpackMainPlugin() && getXpackMainPlugin().info; - if (!xpackInfo) { - // xpackInfo is updated via poll, so it may not be available until polling has begun. - // In this rare situation, tell the client the service is temporarily unavailable. - throw wrapCustomError(new Error('Security info unavailable'), 503); - } - - const privilegesResult: Privileges = { - hasAllPrivileges: true, - missingPrivileges: { - cluster: [], - index: [], - }, - }; - - const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'); - if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) { - // If security isn't enabled, let the user use app. - return privilegesResult; - } - - // Get cluster priviliges - const { has_all_requested: hasAllPrivileges, cluster } = await callWithRequest( - 'transport.request', - { - path: '/_security/user/_has_privileges', - method: 'POST', - body: { - cluster: APP_CLUSTER_PRIVILEGES, - }, - } - ); - - // Find missing cluster privileges and set overall app privileges - privilegesResult.missingPrivileges.cluster = extractMissingPrivileges(cluster); - privilegesResult.hasAllPrivileges = hasAllPrivileges; - - // Get all index privileges the user has - const { indices } = await callWithRequest('transport.request', { - path: '/_security/user/_privileges', - method: 'GET', - }); - - // Check if they have all the required index privileges for at least one index - const oneIndexWithAllPrivileges = indices.find(({ privileges }: { privileges: string[] }) => { - if (privileges.includes('all')) { - return true; - } - - const indexHasAllPrivileges = APP_INDEX_PRIVILEGES.every(privilege => - privileges.includes(privilege) - ); - - return indexHasAllPrivileges; - }); - - // If they don't, return list of required index privileges - if (!oneIndexWithAllPrivileges) { - privilegesResult.missingPrivileges.index = [...APP_INDEX_PRIVILEGES]; - } - - return privilegesResult; -}; diff --git a/x-pack/legacy/plugins/transform/server/routes/api/register_routes.ts b/x-pack/legacy/plugins/transform/server/routes/api/register_routes.ts deleted file mode 100644 index c01647c598d8657..000000000000000 --- a/x-pack/legacy/plugins/transform/server/routes/api/register_routes.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Router } from '../../../../../server/lib/create_router'; -import { Plugins } from '../../shim'; -import { registerAppRoutes } from './app'; -import { registerTransformsRoutes } from './transforms'; - -export const registerRoutes = (router: Router, plugins: Plugins): void => { - registerAppRoutes(router, plugins); - registerTransformsRoutes(router, plugins); -}; diff --git a/x-pack/legacy/plugins/transform/server/routes/api/transform_audit_messages.ts b/x-pack/legacy/plugins/transform/server/routes/api/transform_audit_messages.ts deleted file mode 100644 index c4b5fbd4d3b6098..000000000000000 --- a/x-pack/legacy/plugins/transform/server/routes/api/transform_audit_messages.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; - -import { AuditMessage } from '../../../common/types/messages'; - -const ML_DF_NOTIFICATION_INDEX_PATTERN = '.transform-notifications-read'; -const SIZE = 500; - -interface BoolQuery { - bool: { [key: string]: any }; -} - -export function transformAuditMessagesProvider(callWithRequest: CallCluster) { - // search for audit messages, - // transformId is optional. without it, all transforms will be listed. - async function getTransformAuditMessages(transformId: string) { - const query: BoolQuery = { - bool: { - filter: [ - { - bool: { - must_not: { - term: { - level: 'activity', - }, - }, - }, - }, - ], - }, - }; - - // if no transformId specified, load all of the messages - if (transformId !== undefined) { - query.bool.filter.push({ - bool: { - should: [ - { - term: { - transform_id: '', // catch system messages - }, - }, - { - term: { - transform_id: transformId, // messages for specified transformId - }, - }, - ], - }, - }); - } - - try { - const resp = await callWithRequest('search', { - index: ML_DF_NOTIFICATION_INDEX_PATTERN, - ignore_unavailable: true, - rest_total_hits_as_int: true, - size: SIZE, - body: { - sort: [{ timestamp: { order: 'desc' } }, { transform_id: { order: 'asc' } }], - query, - }, - }); - - let messages = []; - if (resp.hits.total !== 0) { - messages = resp.hits.hits.map((hit: AuditMessage) => hit._source); - messages.reverse(); - } - return messages; - } catch (e) { - throw e; - } - } - - return { - getTransformAuditMessages, - }; -} diff --git a/x-pack/legacy/plugins/transform/server/routes/api/transforms.ts b/x-pack/legacy/plugins/transform/server/routes/api/transforms.ts deleted file mode 100644 index 6e833854a24c9ab..000000000000000 --- a/x-pack/legacy/plugins/transform/server/routes/api/transforms.ts +++ /dev/null @@ -1,261 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; -import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router'; -import { wrapEsError } from '../../../../../server/lib/create_router/error_wrappers'; -import { Plugins } from '../../shim'; -import { TRANSFORM_STATE } from '../../../public/app/common'; -import { - TransformEndpointRequest, - TransformEndpointResult, -} from '../../../public/app/hooks/use_api_types'; -import { TransformId } from '../../../public/app/common/transform'; -import { isRequestTimeout, fillResultsWithTimeouts } from './error_utils'; -import { transformAuditMessagesProvider } from './transform_audit_messages'; - -enum TRANSFORM_ACTIONS { - STOP = 'stop', - START = 'start', - DELETE = 'delete', -} - -interface StartOptions { - transformId: TransformId; -} - -interface StopOptions { - transformId: TransformId; - force: boolean; - waitForCompletion?: boolean; -} - -export function registerTransformsRoutes(router: Router, plugins: Plugins) { - router.get('transforms', getTransformHandler); - router.get('transforms/{transformId}', getTransformHandler); - router.get('transforms/_stats', getTransformStatsHandler); - router.get('transforms/{transformId}/_stats', getTransformStatsHandler); - router.get('transforms/{transformId}/messages', getTransformMessagesHandler); - router.put('transforms/{transformId}', putTransformHandler); - router.post('delete_transforms', deleteTransformsHandler); - router.post('transforms/_preview', previewTransformHandler); - router.post('start_transforms', startTransformsHandler); - router.post('stop_transforms', stopTransformsHandler); - router.post('es_search', esSearchHandler); -} - -const getTransformHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformId } = req.params; - const options = { - ...(transformId !== undefined ? { transformId } : {}), - }; - - try { - return await callWithRequest('transform.getTransforms', options); - } catch (e) { - return { error: wrapEsError(e) }; - } -}; - -const getTransformStatsHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformId } = req.params; - const options = { - ...(transformId !== undefined ? { transformId } : {}), - }; - - try { - return await callWithRequest('transform.getTransformsStats', options); - } catch (e) { - return { error: wrapEsError(e) }; - } -}; - -const deleteTransformsHandler: RouterRouteHandler = async (req, callWithRequest) => { - const transformsInfo = req.payload as TransformEndpointRequest[]; - - try { - return await deleteTransforms(transformsInfo, callWithRequest); - } catch (e) { - return { error: wrapEsError(e) }; - } -}; - -const putTransformHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformId } = req.params; - - const response: { - transformsCreated: Array<{ transform: string }>; - errors: any[]; - } = { - transformsCreated: [], - errors: [], - }; - - await callWithRequest('transform.createTransform', { body: req.payload, transformId }) - .then(() => response.transformsCreated.push({ transform: transformId })) - .catch(e => - response.errors.push({ - id: transformId, - error: wrapEsError(e), - }) - ); - - return response; -}; - -async function deleteTransforms( - transformsInfo: TransformEndpointRequest[], - callWithRequest: CallCluster -) { - const results: TransformEndpointResult = {}; - - for (const transformInfo of transformsInfo) { - const transformId = transformInfo.id; - try { - if (transformInfo.state === TRANSFORM_STATE.FAILED) { - try { - await callWithRequest('transform.stopTransform', { - transformId, - force: true, - waitForCompletion: true, - } as StopOptions); - } catch (e) { - if (isRequestTimeout(e)) { - return fillResultsWithTimeouts({ - results, - id: transformId, - items: transformsInfo, - action: TRANSFORM_ACTIONS.DELETE, - }); - } - } - } - - await callWithRequest('transform.deleteTransform', { transformId }); - results[transformId] = { success: true }; - } catch (e) { - if (isRequestTimeout(e)) { - return fillResultsWithTimeouts({ - results, - id: transformInfo.id, - items: transformsInfo, - action: TRANSFORM_ACTIONS.DELETE, - }); - } - results[transformId] = { success: false, error: JSON.stringify(e) }; - } - } - return results; -} - -const previewTransformHandler: RouterRouteHandler = async (req, callWithRequest) => { - try { - return await callWithRequest('transform.getTransformsPreview', { body: req.payload }); - } catch (e) { - return wrapEsError(e); - } -}; - -const startTransformsHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformsInfo } = req.payload as { - transformsInfo: TransformEndpointRequest[]; - }; - - try { - return await startTransforms(transformsInfo, callWithRequest); - } catch (e) { - return wrapEsError(e); - } -}; - -async function startTransforms( - transformsInfo: TransformEndpointRequest[], - callWithRequest: CallCluster -) { - const results: TransformEndpointResult = {}; - - for (const transformInfo of transformsInfo) { - const transformId = transformInfo.id; - try { - await callWithRequest('transform.startTransform', { transformId } as StartOptions); - results[transformId] = { success: true }; - } catch (e) { - if (isRequestTimeout(e)) { - return fillResultsWithTimeouts({ - results, - id: transformId, - items: transformsInfo, - action: TRANSFORM_ACTIONS.START, - }); - } - results[transformId] = { success: false, error: JSON.stringify(e) }; - } - } - return results; -} - -const stopTransformsHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { transformsInfo } = req.payload as { - transformsInfo: TransformEndpointRequest[]; - }; - - try { - return await stopTransforms(transformsInfo, callWithRequest); - } catch (e) { - return wrapEsError(e); - } -}; - -async function stopTransforms( - transformsInfo: TransformEndpointRequest[], - callWithRequest: CallCluster -) { - const results: TransformEndpointResult = {}; - - for (const transformInfo of transformsInfo) { - const transformId = transformInfo.id; - try { - await callWithRequest('transform.stopTransform', { - transformId, - force: - transformInfo.state !== undefined - ? transformInfo.state === TRANSFORM_STATE.FAILED - : false, - waitForCompletion: true, - } as StopOptions); - results[transformId] = { success: true }; - } catch (e) { - if (isRequestTimeout(e)) { - return fillResultsWithTimeouts({ - results, - id: transformId, - items: transformsInfo, - action: TRANSFORM_ACTIONS.STOP, - }); - } - results[transformId] = { success: false, error: JSON.stringify(e) }; - } - } - return results; -} - -const getTransformMessagesHandler: RouterRouteHandler = async (req, callWithRequest) => { - const { getTransformAuditMessages } = transformAuditMessagesProvider(callWithRequest); - const { transformId } = req.params; - - try { - return await getTransformAuditMessages(transformId); - } catch (e) { - return wrapEsError(e); - } -}; - -const esSearchHandler: RouterRouteHandler = async (req, callWithRequest) => { - try { - return await callWithRequest('search', req.payload); - } catch (e) { - return { error: wrapEsError(e) }; - } -}; diff --git a/x-pack/legacy/plugins/transform/server/shim.ts b/x-pack/legacy/plugins/transform/server/shim.ts deleted file mode 100644 index 8f477d86441f48d..000000000000000 --- a/x-pack/legacy/plugins/transform/server/shim.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -import { createRouter, Router } from '../../../server/lib/create_router'; -import { registerLicenseChecker } from '../../../server/lib/register_license_checker'; -import { elasticsearchJsPlugin } from './client/elasticsearch_transform'; -export interface Core { - http: { - createRouter(basePath: string): Router; - }; -} - -export interface Plugins { - license: { - registerLicenseChecker: typeof registerLicenseChecker; - }; - xpack_main: any; - elasticsearch: any; -} - -export function createServerShim( - server: Legacy.Server, - pluginId: string -): { core: Core; plugins: Plugins } { - return { - core: { - http: { - createRouter: (basePath: string) => - createRouter(server, pluginId, basePath, { - plugins: [elasticsearchJsPlugin], - }), - }, - }, - plugins: { - license: { - registerLicenseChecker, - }, - xpack_main: server.plugins.xpack_main, - elasticsearch: server.plugins.elasticsearch, - }, - }; -} diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json new file mode 100644 index 000000000000000..87e38f83ef64074 --- /dev/null +++ b/x-pack/plugins/transform/kibana.json @@ -0,0 +1,15 @@ +{ + "id": "transform", + "version": "kibana", + "server": true, + "ui": false, + "requiredPlugins": [ + "home", + "licensing", + "management" + ], + "optionalPlugins": [ + "usageCollection" + ], + "configPath": ["xpack", "transform"] +} diff --git a/x-pack/legacy/plugins/transform/server/client/elasticsearch_transform.ts b/x-pack/plugins/transform/server/client/elasticsearch_transform.ts similarity index 100% rename from x-pack/legacy/plugins/transform/server/client/elasticsearch_transform.ts rename to x-pack/plugins/transform/server/client/elasticsearch_transform.ts diff --git a/x-pack/plugins/transform/server/index.ts b/x-pack/plugins/transform/server/index.ts new file mode 100644 index 000000000000000..7b7cf3ee44fb5ca --- /dev/null +++ b/x-pack/plugins/transform/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; + +import { TransformServerPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => new TransformServerPlugin(ctx); diff --git a/x-pack/plugins/transform/server/plugin.ts b/x-pack/plugins/transform/server/plugin.ts new file mode 100644 index 000000000000000..7da991bc02b3762 --- /dev/null +++ b/x-pack/plugins/transform/server/plugin.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { + CoreSetup, + Plugin, + IScopedClusterClient, + Logger, + PluginInitializerContext, +} from 'src/core/server'; + +import { LicenseType } from '../../licensing/common/types'; + +import { elasticsearchJsPlugin } from './client/elasticsearch_transform'; +import { Dependencies } from './types'; +import { ApiRoutes } from './routes'; +import { License } from './services'; + +declare module 'kibana/server' { + interface RequestHandlerContext { + transform?: { + dataClient: IScopedClusterClient; + }; + } +} + +const basicLicense: LicenseType = 'basic'; + +const PLUGIN = { + id: 'transform', + minimumLicenseType: basicLicense, + getI18nName: (): string => + i18n.translate('xpack.transform.appTitle', { + defaultMessage: 'Transforms', + }), +}; + +export class TransformServerPlugin implements Plugin<{}, void, any, any> { + private readonly apiRoutes: ApiRoutes; + private readonly license: License; + private readonly logger: Logger; + + constructor(initContext: PluginInitializerContext) { + this.logger = initContext.logger.get(); + this.apiRoutes = new ApiRoutes(); + this.license = new License(); + } + + setup({ elasticsearch, http }: CoreSetup, { licensing }: Dependencies): {} { + const router = http.createRouter(); + + this.license.setup( + { + pluginId: PLUGIN.id, + minimumLicenseType: PLUGIN.minimumLicenseType, + defaultErrorMessage: i18n.translate('xpack.transform.licenseCheckErrorMessage', { + defaultMessage: 'License check failed', + }), + }, + { + licensing, + logger: this.logger, + } + ); + + this.apiRoutes.setup({ + router, + license: this.license, + }); + + // Can access via new platform router's handler function 'context' parameter - context.transform.client + const transformClient = elasticsearch.createClient('transform', { + plugins: [elasticsearchJsPlugin], + }); + http.registerRouteHandlerContext('transform', (context, request) => { + return { + dataClient: transformClient.asScoped(request), + }; + }); + + return {}; + } + + start() {} + stop() {} +} diff --git a/x-pack/legacy/plugins/transform/server/routes/api/error_utils.ts b/x-pack/plugins/transform/server/routes/api/error_utils.ts similarity index 80% rename from x-pack/legacy/plugins/transform/server/routes/api/error_utils.ts rename to x-pack/plugins/transform/server/routes/api/error_utils.ts index 094c0308ff20ff5..d09152bf1a60314 100644 --- a/x-pack/legacy/plugins/transform/server/routes/api/error_utils.ts +++ b/x-pack/plugins/transform/server/routes/api/error_utils.ts @@ -4,11 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { boomify, isBoom } from 'boom'; + import { i18n } from '@kbn/i18n'; + +import { ResponseError, CustomHttpResponseOptions } from 'src/core/server'; + import { TransformEndpointRequest, TransformEndpointResult, -} from '../../../public/app/hooks/use_api_types'; +} from '../../../../../legacy/plugins/transform/public/app/hooks/use_api_types'; const REQUEST_TIMEOUT = 'RequestTimeout'; @@ -71,3 +76,12 @@ export function fillResultsWithTimeouts({ results, id, items, action }: Params) return accumResults; }, newResults); } + +export function wrapError(error: any): CustomHttpResponseOptions<ResponseError> { + const boom = isBoom(error) ? error : boomify(error, { statusCode: error.status }); + return { + body: boom, + headers: boom.output.headers, + statusCode: boom.output.statusCode, + }; +} diff --git a/x-pack/plugins/transform/server/routes/api/privileges.ts b/x-pack/plugins/transform/server/routes/api/privileges.ts new file mode 100644 index 000000000000000..6003a88ffa40c28 --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/privileges.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + APP_CLUSTER_PRIVILEGES, + APP_INDEX_PRIVILEGES, +} from '../../../../../legacy/plugins/transform/common/constants'; +// NOTE: now we import it from our "public" folder, but when the Authorisation lib +// will move to the "es_ui_shared" plugin, it will be imported from its "static" folder +import { Privileges } from '../../../../../legacy/plugins/transform/public/app/lib/authorization'; + +import { RouteDependencies } from '../../types'; +import { addBasePath } from '../index'; + +export function registerPrivilegesRoute({ router, license }: RouteDependencies) { + router.get( + { path: addBasePath('privileges'), validate: {} }, + license.guardApiRoute(async (ctx, req, res) => { + const privilegesResult: Privileges = { + hasAllPrivileges: true, + missingPrivileges: { + cluster: [], + index: [], + }, + }; + + if (license.getStatus().isSecurityEnabled === false) { + // If security isn't enabled, let the user use app. + return res.ok({ body: privilegesResult }); + } + + // Get cluster priviliges + const { + has_all_requested: hasAllPrivileges, + cluster, + } = await ctx.transform!.dataClient.callAsCurrentUser('transport.request', { + path: '/_security/user/_has_privileges', + method: 'POST', + body: { + cluster: APP_CLUSTER_PRIVILEGES, + }, + }); + + // Find missing cluster privileges and set overall app privileges + privilegesResult.missingPrivileges.cluster = extractMissingPrivileges(cluster); + privilegesResult.hasAllPrivileges = hasAllPrivileges; + + // Get all index privileges the user has + const { indices } = await ctx.transform!.dataClient.callAsCurrentUser('transport.request', { + path: '/_security/user/_privileges', + method: 'GET', + }); + + // Check if they have all the required index privileges for at least one index + const oneIndexWithAllPrivileges = indices.find(({ privileges }: { privileges: string[] }) => { + if (privileges.includes('all')) { + return true; + } + + const indexHasAllPrivileges = APP_INDEX_PRIVILEGES.every(privilege => + privileges.includes(privilege) + ); + + return indexHasAllPrivileges; + }); + + // If they don't, return list of required index privileges + if (!oneIndexWithAllPrivileges) { + privilegesResult.missingPrivileges.index = [...APP_INDEX_PRIVILEGES]; + } + + return res.ok({ body: privilegesResult }); + }) + ); +} + +const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = {}): string[] => + Object.keys(privilegesObject).reduce((privileges: string[], privilegeName: string): string[] => { + if (!privilegesObject[privilegeName]) { + privileges.push(privilegeName); + } + return privileges; + }, []); diff --git a/x-pack/plugins/transform/server/routes/api/schema.ts b/x-pack/plugins/transform/server/routes/api/schema.ts new file mode 100644 index 000000000000000..0b994406d324d86 --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/schema.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +export const schemaTransformId = { + params: schema.object({ + transformId: schema.string(), + }), +}; + +export interface SchemaTransformId { + transformId: string; +} diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts new file mode 100644 index 000000000000000..7aaae1f1c703974 --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -0,0 +1,360 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { RequestHandler } from 'kibana/server'; +import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; + +import { TRANSFORM_STATE } from '../../../../../legacy/plugins/transform/public/app/common'; +import { + TransformEndpointRequest, + TransformEndpointResult, +} from '../../../../../legacy/plugins/transform/public/app/hooks/use_api_types'; +import { TransformId } from '../../../../../legacy/plugins/transform/public/app/common/transform'; + +import { RouteDependencies } from '../../types'; + +import { addBasePath } from '../index'; + +import { isRequestTimeout, fillResultsWithTimeouts, wrapError } from './error_utils'; +import { schemaTransformId, SchemaTransformId } from './schema'; +import { registerTransformsAuditMessagesRoutes } from './transforms_audit_messages'; + +enum TRANSFORM_ACTIONS { + STOP = 'stop', + START = 'start', + DELETE = 'delete', +} + +interface StopOptions { + transformId: TransformId; + force: boolean; + waitForCompletion?: boolean; +} + +export function registerTransformsRoutes(routeDependencies: RouteDependencies) { + const { router, license } = routeDependencies; + router.get( + { path: addBasePath('transforms'), validate: false }, + license.guardApiRoute(async (ctx, req, res) => { + const options = {}; + try { + const transforms = await getTransforms( + options, + ctx.transform!.dataClient.callAsCurrentUser + ); + return res.ok({ body: transforms }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + router.get( + { + path: addBasePath('transforms/{transformId}'), + validate: schemaTransformId, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params as SchemaTransformId; + const options = { + ...(transformId !== undefined ? { transformId } : {}), + }; + try { + const transforms = await getTransforms( + options, + ctx.transform!.dataClient.callAsCurrentUser + ); + return res.ok({ body: transforms }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + router.get( + { path: addBasePath('transforms/_stats'), validate: false }, + license.guardApiRoute(async (ctx, req, res) => { + const options = {}; + try { + const stats = await ctx.transform!.dataClient.callAsCurrentUser( + 'transform.getTransformsStats', + options + ); + return res.ok({ body: stats }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + router.get( + { + path: addBasePath('transforms/{transformId}/_stats'), + validate: schemaTransformId, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params as SchemaTransformId; + const options = { + ...(transformId !== undefined ? { transformId } : {}), + }; + try { + const stats = await ctx.transform!.dataClient.callAsCurrentUser( + 'transform.getTransformsStats', + options + ); + return res.ok({ body: stats }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + registerTransformsAuditMessagesRoutes(routeDependencies); + router.put( + { + path: addBasePath('transforms/{transformId}'), + validate: { + ...schemaTransformId, + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params as SchemaTransformId; + + const response: { + transformsCreated: Array<{ transform: string }>; + errors: any[]; + } = { + transformsCreated: [], + errors: [], + }; + + await ctx + .transform!.dataClient.callAsCurrentUser('transform.createTransform', { + body: req.body, + transformId, + }) + .then(() => response.transformsCreated.push({ transform: transformId })) + .catch(e => + response.errors.push({ + id: transformId, + error: wrapEsError(e), + }) + ); + + return res.ok({ body: response }); + }) + ); + router.post( + { + path: addBasePath('delete_transforms'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const transformsInfo = req.body as TransformEndpointRequest[]; + + try { + return res.ok({ + body: await deleteTransforms(transformsInfo, ctx.transform!.dataClient.callAsCurrentUser), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); + router.post( + { + path: addBasePath('transforms/_preview'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(previewTransformHandler) + ); + router.post( + { + path: addBasePath('start_transforms'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(startTransformsHandler) + ); + router.post( + { + path: addBasePath('stop_transforms'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(stopTransformsHandler) + ); + router.post( + { + path: addBasePath('es_search'), + validate: { + body: schema.maybe(schema.any()), + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + try { + return res.ok({ + body: await ctx.transform!.dataClient.callAsCurrentUser('search', req.body), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); +} + +const getTransforms = async (options: { transformId?: string }, callAsCurrentUser: CallCluster) => { + return await callAsCurrentUser('transform.getTransforms', options); +}; + +async function deleteTransforms( + transformsInfo: TransformEndpointRequest[], + callAsCurrentUser: CallCluster +) { + const results: TransformEndpointResult = {}; + + for (const transformInfo of transformsInfo) { + const transformId = transformInfo.id; + try { + if (transformInfo.state === TRANSFORM_STATE.FAILED) { + try { + await callAsCurrentUser('transform.stopTransform', { + transformId, + force: true, + waitForCompletion: true, + } as StopOptions); + } catch (e) { + if (isRequestTimeout(e)) { + return fillResultsWithTimeouts({ + results, + id: transformId, + items: transformsInfo, + action: TRANSFORM_ACTIONS.DELETE, + }); + } + } + } + + await callAsCurrentUser('transform.deleteTransform', { transformId }); + results[transformId] = { success: true }; + } catch (e) { + if (isRequestTimeout(e)) { + return fillResultsWithTimeouts({ + results, + id: transformInfo.id, + items: transformsInfo, + action: TRANSFORM_ACTIONS.DELETE, + }); + } + results[transformId] = { success: false, error: JSON.stringify(e) }; + } + } + return results; +} + +const previewTransformHandler: RequestHandler = async (ctx, req, res) => { + try { + return res.ok({ + body: await ctx.transform!.dataClient.callAsCurrentUser('transform.getTransformsPreview', { + body: req.body, + }), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } +}; + +const startTransformsHandler: RequestHandler = async (ctx, req, res) => { + const { transformsInfo } = req.body as { + transformsInfo: TransformEndpointRequest[]; + }; + + try { + return res.ok({ + body: await startTransforms(transformsInfo, ctx.transform!.dataClient.callAsCurrentUser), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } +}; + +async function startTransforms( + transformsInfo: TransformEndpointRequest[], + callAsCurrentUser: CallCluster +) { + const results: TransformEndpointResult = {}; + + for (const transformInfo of transformsInfo) { + const transformId = transformInfo.id; + try { + await callAsCurrentUser('transform.startTransform', { transformId } as SchemaTransformId); + results[transformId] = { success: true }; + } catch (e) { + if (isRequestTimeout(e)) { + return fillResultsWithTimeouts({ + results, + id: transformId, + items: transformsInfo, + action: TRANSFORM_ACTIONS.START, + }); + } + results[transformId] = { success: false, error: JSON.stringify(e) }; + } + } + return results; +} + +const stopTransformsHandler: RequestHandler = async (ctx, req, res) => { + const { transformsInfo } = req.body as { + transformsInfo: TransformEndpointRequest[]; + }; + + try { + return res.ok({ + body: await stopTransforms(transformsInfo, ctx.transform!.dataClient.callAsCurrentUser), + }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } +}; + +async function stopTransforms( + transformsInfo: TransformEndpointRequest[], + callAsCurrentUser: CallCluster +) { + const results: TransformEndpointResult = {}; + + for (const transformInfo of transformsInfo) { + const transformId = transformInfo.id; + try { + await callAsCurrentUser('transform.stopTransform', { + transformId, + force: + transformInfo.state !== undefined + ? transformInfo.state === TRANSFORM_STATE.FAILED + : false, + waitForCompletion: true, + } as StopOptions); + results[transformId] = { success: true }; + } catch (e) { + if (isRequestTimeout(e)) { + return fillResultsWithTimeouts({ + results, + id: transformId, + items: transformsInfo, + action: TRANSFORM_ACTIONS.STOP, + }); + } + results[transformId] = { success: false, error: JSON.stringify(e) }; + } + } + return results; +} diff --git a/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts new file mode 100644 index 000000000000000..422fdec7ab77e01 --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/transforms_audit_messages.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AuditMessage } from '../../../../../legacy/plugins/transform/common/types/messages'; +import { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; + +import { RouteDependencies } from '../../types'; + +import { addBasePath } from '../index'; + +import { wrapError } from './error_utils'; +import { schemaTransformId, SchemaTransformId } from './schema'; + +const ML_DF_NOTIFICATION_INDEX_PATTERN = '.transform-notifications-read'; +const SIZE = 500; + +interface BoolQuery { + bool: { [key: string]: any }; +} + +export function registerTransformsAuditMessagesRoutes({ router, license }: RouteDependencies) { + router.get( + { path: addBasePath('transforms/{transformId}/messages'), validate: schemaTransformId }, + license.guardApiRoute(async (ctx, req, res) => { + const { transformId } = req.params as SchemaTransformId; + + // search for audit messages, + // transformId is optional. without it, all transforms will be listed. + const query: BoolQuery = { + bool: { + filter: [ + { + bool: { + must_not: { + term: { + level: 'activity', + }, + }, + }, + }, + ], + }, + }; + + // if no transformId specified, load all of the messages + if (transformId !== undefined) { + query.bool.filter.push({ + bool: { + should: [ + { + term: { + transform_id: '', // catch system messages + }, + }, + { + term: { + transform_id: transformId, // messages for specified transformId + }, + }, + ], + }, + }); + } + + try { + const resp = await ctx.transform!.dataClient.callAsCurrentUser('search', { + index: ML_DF_NOTIFICATION_INDEX_PATTERN, + ignore_unavailable: true, + rest_total_hits_as_int: true, + size: SIZE, + body: { + sort: [{ timestamp: { order: 'desc' } }, { transform_id: { order: 'asc' } }], + query, + }, + }); + + let messages = []; + if (resp.hits.total !== 0) { + messages = resp.hits.hits.map((hit: AuditMessage) => hit._source); + messages.reverse(); + } + return res.ok({ body: messages }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); +} diff --git a/x-pack/plugins/transform/server/routes/index.ts b/x-pack/plugins/transform/server/routes/index.ts new file mode 100644 index 000000000000000..953490920cbcb15 --- /dev/null +++ b/x-pack/plugins/transform/server/routes/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteDependencies } from '../types'; + +import { registerPrivilegesRoute } from './api/privileges'; +import { registerTransformsRoutes } from './api/transforms'; + +import { API_BASE_PATH } from '../../../../legacy/plugins/transform/common/constants'; + +export const addBasePath = (uri: string): string => `${API_BASE_PATH}${uri}`; + +export class ApiRoutes { + setup(dependencies: RouteDependencies) { + registerPrivilegesRoute(dependencies); + registerTransformsRoutes(dependencies); + } + + start() {} + stop() {} +} diff --git a/x-pack/plugins/transform/server/services/index.ts b/x-pack/plugins/transform/server/services/index.ts new file mode 100644 index 000000000000000..b7a45e59549eb76 --- /dev/null +++ b/x-pack/plugins/transform/server/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { License } from './license'; diff --git a/x-pack/plugins/transform/server/services/license.ts b/x-pack/plugins/transform/server/services/license.ts new file mode 100644 index 000000000000000..93346160c6f446f --- /dev/null +++ b/x-pack/plugins/transform/server/services/license.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Logger } from 'src/core/server'; +import { + IKibanaResponse, + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, +} from 'kibana/server'; + +import { LicensingPluginSetup, LicenseType, LICENSE_CHECK_STATE } from '../../../licensing/server'; + +export interface LicenseStatus { + isValid: boolean; + isSecurityEnabled: boolean; + message?: string; +} + +interface SetupSettings { + pluginId: string; + minimumLicenseType: LicenseType; + defaultErrorMessage: string; +} + +export class License { + private licenseStatus: LicenseStatus = { + isValid: false, + isSecurityEnabled: false, + message: 'Invalid License', + }; + + setup( + { pluginId, minimumLicenseType, defaultErrorMessage }: SetupSettings, + { licensing, logger }: { licensing: LicensingPluginSetup; logger: Logger } + ) { + licensing.license$.subscribe(license => { + const { state, message } = license.check(pluginId, minimumLicenseType); + const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid; + + const securityFeature = license.getFeature('security'); + const isSecurityEnabled = + securityFeature !== undefined && + securityFeature.isAvailable === true && + securityFeature.isEnabled === true; + + if (hasRequiredLicense) { + this.licenseStatus = { isValid: true, isSecurityEnabled }; + } else { + this.licenseStatus = { + isValid: false, + isSecurityEnabled, + message: message || defaultErrorMessage, + }; + if (message) { + logger.info(message); + } + } + }); + } + + guardApiRoute(handler: RequestHandler<unknown, unknown, any, any>) { + const license = this; + + return function licenseCheck( + ctx: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ): IKibanaResponse<any> | Promise<IKibanaResponse<any>> { + const licenseStatus = license.getStatus(); + + if (!licenseStatus.isValid) { + return response.customError({ + body: { + message: licenseStatus.message || '', + }, + statusCode: 403, + }); + } + + return handler(ctx, request, response); + }; + } + + getStatus() { + return this.licenseStatus; + } +} diff --git a/x-pack/plugins/transform/server/types.ts b/x-pack/plugins/transform/server/types.ts new file mode 100644 index 000000000000000..5fcc23a6d9f4848 --- /dev/null +++ b/x-pack/plugins/transform/server/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { License } from './services'; + +export interface Dependencies { + licensing: LicensingPluginSetup; +} + +export interface RouteDependencies { + router: IRouter; + license: License; +} From 4712faefb76facc4c7ae400aa140fe2a0ac0dfde Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens <jloleysens@gmail.com> Date: Tue, 3 Mar 2020 09:06:00 +0100 Subject: [PATCH 10/22] Fix the namespace for indices autocompletion (#59043) --- .../spec_definitions/spec/overrides/indices.put_settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/console/server/lib/spec_definitions/spec/overrides/indices.put_settings.json b/src/plugins/console/server/lib/spec_definitions/spec/overrides/indices.put_settings.json index 78a5bbdbcf6c2b4..2e1e3024665a4ae 100644 --- a/src/plugins/console/server/lib/spec_definitions/spec/overrides/indices.put_settings.json +++ b/src/plugins/console/server/lib/spec_definitions/spec/overrides/indices.put_settings.json @@ -1,5 +1,5 @@ { - "put_settings": { + "indices.put_settings": { "data_autocomplete_rules": { "refresh_interval": "1s", "number_of_shards": 1, @@ -105,4 +105,4 @@ } } } -} \ No newline at end of file +} From 166716a405d737bdb33b6c4a5cc75107a59d80b4 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Tue, 3 Mar 2020 12:39:52 +0300 Subject: [PATCH 11/22] [Visualize] Move linked search to react component (#58590) * Move linked_search to react * Use i18n from start contract * Move linked search to the editor * Updating layout and fixing truncation * Fix functional test, add a tooltip Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com> --- .../visualize/np_ready/editor/_editor.scss | 4 - .../visualize/np_ready/editor/editor.html | 24 --- .../visualize/np_ready/editor/editor.js | 20 +- .../np_ready/editor/visualization_editor.js | 1 + .../public/visualize/np_ready/types.d.ts | 4 + .../vis_default_editor/public/_sidebar.scss | 6 +- .../public/components/sidebar/sidebar.tsx | 26 +-- .../components/sidebar/sidebar_title.tsx | 175 ++++++++++++++++++ .../public/default_editor.tsx | 5 +- .../functional/page_objects/visualize_page.ts | 3 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 12 files changed, 216 insertions(+), 56 deletions(-) create mode 100644 src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss index 2f48ecc322fea53..3a542cacc44be24 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/_editor.scss @@ -22,10 +22,6 @@ a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */ } } -.visEditor__linkedMessage { - padding: $euiSizeS; -} - .visEditor__content { @include flex-parent(); width: 100%; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html index 4979d9dc89a0ce2..9dbb05ea95b4876 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.html @@ -1,28 +1,4 @@ <visualize-app class="app-container visEditor visEditor--{{ vis.type.name }}"> - <!-- Linked search. --> - <div - ng-show="isVisible" - ng-if="vis.type.requiresSearch && linked" - class="fullWidth visEditor__linkedMessage" - > - <div class="kuiVerticalRhythmSmall"> - {{ ::'kbn.visualize.linkedToSearchInfoText' | i18n: { defaultMessage: 'Linked to Saved Search' } }} - <a - href="#/discover/{{savedVis.savedSearch.id}}" - > - {{ savedVis.savedSearch.title }} - </a> -   - <a - data-test-subj="unlinkSavedSearch" - href="" - ng-dblclick="unlink()" - tooltip="{{ ::'kbn.visualize.linkedToSearch.unlinkButtonTooltip' | i18n: { defaultMessage: 'Double click to unlink from Saved Search' } }}" - > - <span aria-hidden="true" class="kuiIcon fa-chain-broken"></span> - </a> - </div> - </div> <!-- Local nav. diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 2137e413451d2df..293327f3f72f9ff 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -551,6 +551,20 @@ function VisualizeAppController( updateStateFromSavedQuery(newSavedQuery); }); + $scope.$watch('linked', linked => { + if (linked && !savedVis.savedSearchId) { + savedVis.savedSearchId = savedVis.searchSource.id; + vis.savedSearchId = savedVis.searchSource.id; + + $scope.$broadcast('render'); + } else if (!linked && savedVis.savedSearchId) { + delete savedVis.savedSearchId; + delete vis.savedSearchId; + + $scope.$broadcast('render'); + } + }); + /** * Called when the user clicks "Save" button. */ @@ -638,9 +652,7 @@ function VisualizeAppController( ); } - $scope.unlink = function() { - if (!$scope.linked) return; - + const unlinkFromSavedSearch = () => { const searchSourceParent = searchSource.getParent(); const searchSourceGrandparent = searchSourceParent.getParent(); @@ -681,6 +693,8 @@ function VisualizeAppController( ); }; + vis.on('unlinkFromSavedSearch', unlinkFromSavedSearch); + addHelpMenuToAppChrome(chrome, docLinks); init(); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js index 65c25b8cf705d24..f2d9cbe2ad84c97 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/visualization_editor.js @@ -43,6 +43,7 @@ export function initVisEditorDirective(app, deps) { filters: $scope.filters, query: $scope.query, appState: $scope.appState, + linked: !!$scope.savedObj.savedSearchId, }); }; diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts index d95939170419b74..8ca603eb1145900 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/types.d.ts @@ -59,6 +59,10 @@ export interface EditorRenderProps { uiState: PersistedState; timeRange: TimeRange; query?: Query; + /** + * Flag to determine if visualiztion is linked to the saved search + */ + linked: boolean; } export interface SavedVisualizations { diff --git a/src/legacy/core_plugins/vis_default_editor/public/_sidebar.scss b/src/legacy/core_plugins/vis_default_editor/public/_sidebar.scss index a38c729cb4622c6..ed92dc1dae88497 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/_sidebar.scss +++ b/src/legacy/core_plugins/vis_default_editor/public/_sidebar.scss @@ -33,8 +33,7 @@ // NAVIGATION // -.visEditorSidebar__indexPattern { - @include euiTextTruncate; +.visEditorSidebar__titleContainer { padding: $euiSizeS $euiSizeXL $euiSizeS $euiSizeS; // Extra padding on the right for the collapse button } @@ -43,7 +42,8 @@ border-bottom: $euiBorderThin; } -.visEditorSidebar__nav { +.visEditorSidebar__nav, +.visEditorSidebar__linkedSearch { flex-grow: 0; } diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx index d3b843eaaec9f21..425245fe91fed9b 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar.tsx @@ -20,13 +20,16 @@ import React, { useMemo, useState, useCallback, KeyboardEventHandler, useEffect } from 'react'; import { get, isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { keyCodes, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { keyCodes, EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + import { Vis } from 'src/legacy/core_plugins/visualizations/public'; import { AggGroupNames } from '../../legacy_imports'; import { DefaultEditorNavBar, OptionTab } from './navbar'; import { DefaultEditorControls } from './controls'; import { setStateParamValue, useEditorReducer, useEditorFormState, discardChanges } from './state'; import { DefaultEditorAggCommonProps } from '../agg_common_props'; +import { SidebarTitle } from './sidebar_title'; +import { SavedSearch } from '../../../../kibana/public/discover/np_ready/types'; import { PersistedState } from '../../../../../../plugins/visualizations/public'; interface DefaultEditorSideBarProps { @@ -35,6 +38,8 @@ interface DefaultEditorSideBarProps { optionTabs: OptionTab[]; uiState: PersistedState; vis: Vis; + isLinkedSearch: boolean; + savedSearch?: SavedSearch; } function DefaultEditorSideBar({ @@ -43,6 +48,8 @@ function DefaultEditorSideBar({ optionTabs, uiState, vis, + isLinkedSearch, + savedSearch, }: DefaultEditorSideBarProps) { const [selectedTab, setSelectedTab] = useState(optionTabs[0].name); const [isDirty, setDirty] = useState(false); @@ -161,21 +168,8 @@ function DefaultEditorSideBar({ name="visualizeEditor" onKeyDownCapture={onSubmit} > - {vis.type.requiresSearch && vis.type.options.showIndexSelection ? ( - <EuiTitle size="xs" className="visEditorSidebar__indexPattern"> - <h2 - title={i18n.translate('visDefaultEditor.sidebar.indexPatternAriaLabel', { - defaultMessage: 'Index pattern: {title}', - values: { - title: vis.indexPattern.title, - }, - })} - > - {vis.indexPattern.title} - </h2> - </EuiTitle> - ) : ( - <div className="visEditorSidebar__indexPatternPlaceholder" /> + {vis.type.requiresSearch && ( + <SidebarTitle isLinkedSearch={isLinkedSearch} savedSearch={savedSearch} vis={vis} /> )} {optionTabs.length > 1 && ( diff --git a/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx new file mode 100644 index 000000000000000..3fd82f1c4a2b67b --- /dev/null +++ b/src/legacy/core_plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx @@ -0,0 +1,175 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiPopoverTitle, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { Vis } from 'src/legacy/core_plugins/visualizations/public'; +import { SavedSearch } from '../../../../kibana/public/discover/np_ready/types'; + +interface LinkedSearchProps { + savedSearch: SavedSearch; + vis: Vis; +} + +interface SidebarTitleProps { + isLinkedSearch: boolean; + savedSearch?: SavedSearch; + vis: Vis; +} + +export function LinkedSearch({ savedSearch, vis }: LinkedSearchProps) { + const [showPopover, setShowPopover] = useState(false); + const closePopover = useCallback(() => setShowPopover(false), []); + const onClickButtonLink = useCallback(() => setShowPopover(v => !v), []); + const onClickUnlikFromSavedSearch = useCallback(() => { + setShowPopover(false); + vis.emit('unlinkFromSavedSearch'); + }, [vis]); + + const linkButtonAriaLabel = i18n.translate( + 'visDefaultEditor.sidebar.savedSearch.linkButtonAriaLabel', + { + defaultMessage: 'Link to saved search. Click to learn more or break link.', + } + ); + + return ( + <EuiFlexGroup + alignItems="center" + className="visEditorSidebar__titleContainer visEditorSidebar__linkedSearch" + gutterSize="xs" + responsive={false} + > + <EuiFlexItem grow={false}> + <EuiIcon type="search" /> + </EuiFlexItem> + + <EuiFlexItem grow={false} className="eui-textTruncate"> + <EuiTitle size="xs" className="eui-textTruncate"> + <h2 + title={i18n.translate('visDefaultEditor.sidebar.savedSearch.titleAriaLabel', { + defaultMessage: 'Saved search: {title}', + values: { + title: savedSearch.title, + }, + })} + > + {savedSearch.title} + </h2> + </EuiTitle> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiPopover + anchorPosition="downRight" + button={ + <EuiToolTip content={linkButtonAriaLabel}> + <EuiButtonIcon + aria-label={linkButtonAriaLabel} + data-test-subj="showUnlinkSavedSearchPopover" + iconType="link" + onClick={onClickButtonLink} + /> + </EuiToolTip> + } + isOpen={showPopover} + closePopover={closePopover} + panelPaddingSize="s" + > + <EuiPopoverTitle> + <FormattedMessage + id="visDefaultEditor.sidebar.savedSearch.popoverTitle" + defaultMessage="Linked to saved search" + /> + </EuiPopoverTitle> + <div style={{ width: 260 }}> + <EuiText size="s"> + <p> + <EuiButtonEmpty flush="left" href={`#/discover/${savedSearch.id}`} size="xs"> + <FormattedMessage + id="visDefaultEditor.sidebar.savedSearch.goToDiscoverButtonText" + defaultMessage="View this search in Discover" + /> + </EuiButtonEmpty> + </p> + <p> + <FormattedMessage + id="visDefaultEditor.sidebar.savedSearch.popoverHelpText" + defaultMessage="Subsequent modifications to this saved search are reflected in the visualization. To disable automatic updates, remove the link." + /> + </p> + <p> + <EuiButton + color="danger" + data-test-subj="unlinkSavedSearch" + fullWidth + onClick={onClickUnlikFromSavedSearch} + size="s" + > + <FormattedMessage + id="visDefaultEditor.sidebar.savedSearch.unlinkSavedSearchButtonText" + defaultMessage="Remove link to saved search" + /> + </EuiButton> + </p> + </EuiText> + </div> + </EuiPopover> + </EuiFlexItem> + </EuiFlexGroup> + ); +} + +function SidebarTitle({ savedSearch, vis, isLinkedSearch }: SidebarTitleProps) { + return isLinkedSearch && savedSearch ? ( + <LinkedSearch savedSearch={savedSearch} vis={vis} /> + ) : vis.type.options.showIndexSelection ? ( + <EuiTitle size="xs" className="visEditorSidebar__titleContainer eui-textTruncate"> + <h2 + title={i18n.translate('visDefaultEditor.sidebar.indexPatternAriaLabel', { + defaultMessage: 'Index pattern: {title}', + values: { + title: vis.indexPattern.title, + }, + })} + > + {vis.indexPattern.title} + </h2> + </EuiTitle> + ) : ( + <div className="visEditorSidebar__indexPatternPlaceholder" /> + ); +} + +export { SidebarTitle }; diff --git a/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx b/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx index 7eee54006f68449..fa3213d244e7e51 100644 --- a/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx +++ b/src/legacy/core_plugins/vis_default_editor/public/default_editor.tsx @@ -40,12 +40,13 @@ function DefaultEditor({ appState, optionTabs, query, + linked, }: DefaultEditorControllerState & Omit<EditorRenderProps, 'data' | 'core'>) { const visRef = useRef<HTMLDivElement>(null); const visHandler = useRef<VisualizeEmbeddable | null>(null); const [isCollapsed, setIsCollapsed] = useState(false); const [factory, setFactory] = useState<VisualizeEmbeddableFactory | null>(null); - const { vis } = savedObj; + const { vis, savedSearch } = savedObj; const onClickCollapse = useCallback(() => { setIsCollapsed(value => !value); @@ -117,6 +118,8 @@ function DefaultEditor({ optionTabs={optionTabs} vis={vis} uiState={uiState} + isLinkedSearch={linked} + savedSearch={savedSearch} /> </Panel> </PanelsContainer> diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index e54e3d1d011540a..82ef3dc800f6c52 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -209,7 +209,8 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide } public async clickUnlinkSavedSearch() { - await testSubjects.doubleClick('unlinkSavedSearch'); + await testSubjects.click('showUnlinkSavedSearchPopover'); + await testSubjects.click('unlinkSavedSearch'); await header.waitUntilLoadingHasFinished(); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a97cf608abc7128..cadebcad935106e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1568,9 +1568,7 @@ "kbn.visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "indexPattern または savedSearchId が必要です", "kbn.visualize.editor.createBreadcrumb": "作成", "kbn.visualize.experimentalVisInfoText": "このビジュアライゼーションは実験的なものです。", - "kbn.visualize.linkedToSearch.unlinkButtonTooltip": "保存された検索からリンクを解除するにはダブルクリックします", "kbn.visualize.linkedToSearch.unlinkSuccessNotificationText": "保存された検索「{searchTitle}」からリンクが解除されました", - "kbn.visualize.linkedToSearchInfoText": "保存された検索にリンクされています", "kbn.visualize.listing.betaTitle": "ベータ", "kbn.visualize.listing.betaTooltip": "このビジュアライゼーションはベータ段階で、変更される可能性があります。デザインとコードはオフィシャル GA 機能よりも完成度が低く、現状のまま保証なしで提供されています。ベータ機能にはオフィシャル GA 機能の SLA が適用されません", "kbn.visualize.listing.breadcrumb": "可視化", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e6055680e1240b0..ff72f20b248644d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1568,9 +1568,7 @@ "kbn.visualize.createVisualization.noIndexPatternOrSavedSearchIdErrorMessage": "必须提供 indexPattern 或 savedSearchId", "kbn.visualize.editor.createBreadcrumb": "创建", "kbn.visualize.experimentalVisInfoText": "此可视化标记为“实验”。", - "kbn.visualize.linkedToSearch.unlinkButtonTooltip": "双击可取消与“已保存搜索”的链接", "kbn.visualize.linkedToSearch.unlinkSuccessNotificationText": "取消与已保存搜索 “{searchTitle}” 的链接", - "kbn.visualize.linkedToSearchInfoText": "链接到“已保存搜索”", "kbn.visualize.listing.betaTitle": "公测版", "kbn.visualize.listing.betaTooltip": "此可视化为公测版,可能会进行更改。设计和代码相对于正式发行版功能还不够成熟,将按原样提供,且不提供任何保证。公测版功能不受正式发行版功能支持 SLA 的约束", "kbn.visualize.listing.breadcrumb": "可视化", From 64ffae3ec5f5b5c977c4b0702ead681866bf4972 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet <pierre.gayvallet@elastic.co> Date: Tue, 3 Mar 2020 10:54:03 +0100 Subject: [PATCH 12/22] Add core metrics service (#58623) * create base service and collectors * wire the service into server, add mock * add collector tests * add main collector test * export metric types from server * add service and server tests * updates generated doc * improve doc * nits and comments * add disconnected requests test --- .../core/server/kibana-plugin-server.md | 5 + ...rver.metricsservicesetup.getopsmetrics_.md | 24 +++ ...ibana-plugin-server.metricsservicesetup.md | 20 ++ ...erver.opsmetrics.concurrent_connections.md | 13 ++ .../server/kibana-plugin-server.opsmetrics.md | 24 +++ .../kibana-plugin-server.opsmetrics.os.md | 13 ++ ...kibana-plugin-server.opsmetrics.process.md | 13 ++ ...ibana-plugin-server.opsmetrics.requests.md | 13 ++ ...plugin-server.opsmetrics.response_times.md | 13 ++ ...ibana-plugin-server.opsosmetrics.distro.md | 13 ++ ...lugin-server.opsosmetrics.distrorelease.md | 13 ++ .../kibana-plugin-server.opsosmetrics.load.md | 17 ++ .../kibana-plugin-server.opsosmetrics.md | 26 +++ ...ibana-plugin-server.opsosmetrics.memory.md | 17 ++ ...ana-plugin-server.opsosmetrics.platform.md | 13 ++ ...gin-server.opsosmetrics.platformrelease.md | 13 ++ ...in-server.opsosmetrics.uptime_in_millis.md | 13 ++ ...rver.opsprocessmetrics.event_loop_delay.md | 13 ++ .../kibana-plugin-server.opsprocessmetrics.md | 23 +++ ...-plugin-server.opsprocessmetrics.memory.md | 20 ++ ...ana-plugin-server.opsprocessmetrics.pid.md | 13 ++ ...rver.opsprocessmetrics.uptime_in_millis.md | 13 ++ ...opsservermetrics.concurrent_connections.md | 13 ++ .../kibana-plugin-server.opsservermetrics.md | 22 +++ ...plugin-server.opsservermetrics.requests.md | 17 ++ ...-server.opsservermetrics.response_times.md | 16 ++ package.json | 1 + src/core/server/index.ts | 8 + src/core/server/internal_types.ts | 2 + src/core/server/legacy/legacy_service.test.ts | 2 + src/core/server/metrics/collectors/index.ts | 23 +++ src/core/server/metrics/collectors/os.test.ts | 99 ++++++++++ src/core/server/metrics/collectors/os.ts | 60 ++++++ .../server/metrics/collectors/process.test.ts | 81 ++++++++ src/core/server/metrics/collectors/process.ts | 52 +++++ src/core/server/metrics/collectors/server.ts | 80 ++++++++ src/core/server/metrics/collectors/types.ts | 110 +++++++++++ src/core/server/metrics/index.ts | 29 +++ .../server_collector.test.ts | 183 ++++++++++++++++++ .../server/metrics/metrics_service.mock.ts | 67 +++++++ .../metrics/metrics_service.test.mocks.ts | 25 +++ .../server/metrics/metrics_service.test.ts | 134 +++++++++++++ src/core/server/metrics/metrics_service.ts | 86 ++++++++ src/core/server/metrics/ops_config.ts | 29 +++ .../ops_metrics_collector.test.mocks.ts | 39 ++++ .../metrics/ops_metrics_collector.test.ts | 59 ++++++ .../server/metrics/ops_metrics_collector.ts | 52 +++++ src/core/server/metrics/types.ts | 66 +++++++ src/core/server/mocks.ts | 5 +- src/core/server/server.api.md | 62 ++++++ src/core/server/server.test.mocks.ts | 6 + src/core/server/server.test.ts | 9 + src/core/server/server.ts | 9 + 53 files changed, 1790 insertions(+), 1 deletion(-) create mode 100644 docs/development/core/server/kibana-plugin-server.metricsservicesetup.getopsmetrics_.md create mode 100644 docs/development/core/server/kibana-plugin-server.metricsservicesetup.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.concurrent_connections.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.os.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.process.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.requests.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsmetrics.response_times.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.distro.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.distrorelease.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.load.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.memory.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.platform.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.platformrelease.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsosmetrics.uptime_in_millis.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.event_loop_delay.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.memory.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.pid.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsservermetrics.concurrent_connections.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsservermetrics.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsservermetrics.requests.md create mode 100644 docs/development/core/server/kibana-plugin-server.opsservermetrics.response_times.md create mode 100644 src/core/server/metrics/collectors/index.ts create mode 100644 src/core/server/metrics/collectors/os.test.ts create mode 100644 src/core/server/metrics/collectors/os.ts create mode 100644 src/core/server/metrics/collectors/process.test.ts create mode 100644 src/core/server/metrics/collectors/process.ts create mode 100644 src/core/server/metrics/collectors/server.ts create mode 100644 src/core/server/metrics/collectors/types.ts create mode 100644 src/core/server/metrics/index.ts create mode 100644 src/core/server/metrics/integration_tests/server_collector.test.ts create mode 100644 src/core/server/metrics/metrics_service.mock.ts create mode 100644 src/core/server/metrics/metrics_service.test.mocks.ts create mode 100644 src/core/server/metrics/metrics_service.test.ts create mode 100644 src/core/server/metrics/metrics_service.ts create mode 100644 src/core/server/metrics/ops_config.ts create mode 100644 src/core/server/metrics/ops_metrics_collector.test.mocks.ts create mode 100644 src/core/server/metrics/ops_metrics_collector.test.ts create mode 100644 src/core/server/metrics/ops_metrics_collector.ts create mode 100644 src/core/server/metrics/types.ts diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 15a1fd050625685..c948c8992079682 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -88,11 +88,16 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- --> | [Logger](./kibana-plugin-server.logger.md) | Logger exposes all the necessary methods to log any type of information and this is the interface used by the logging consumers including plugins. | | [LoggerFactory](./kibana-plugin-server.loggerfactory.md) | The single purpose of <code>LoggerFactory</code> interface is to define a way to retrieve a context-based logger instance. | | [LogMeta](./kibana-plugin-server.logmeta.md) | Contextual metadata | +| [MetricsServiceSetup](./kibana-plugin-server.metricsservicesetup.md) | APIs to retrieves metrics gathered and exposed by the core platform. | | [OnPostAuthToolkit](./kibana-plugin-server.onpostauthtoolkit.md) | A tool set defining an outcome of OnPostAuth interceptor for incoming request. | | [OnPreAuthToolkit](./kibana-plugin-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [OnPreResponseExtensions](./kibana-plugin-server.onpreresponseextensions.md) | Additional data to extend a response. | | [OnPreResponseInfo](./kibana-plugin-server.onpreresponseinfo.md) | Response status code. | | [OnPreResponseToolkit](./kibana-plugin-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | +| [OpsMetrics](./kibana-plugin-server.opsmetrics.md) | Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. | +| [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) | OS related metrics | +| [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) | Process related metrics | +| [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) | server related metrics | | [PackageInfo](./kibana-plugin-server.packageinfo.md) | | | [Plugin](./kibana-plugin-server.plugin.md) | The interface that should be returned by a <code>PluginInitializer</code>. | | [PluginConfigDescriptor](./kibana-plugin-server.pluginconfigdescriptor.md) | Describes a plugin configuration properties. | diff --git a/docs/development/core/server/kibana-plugin-server.metricsservicesetup.getopsmetrics_.md b/docs/development/core/server/kibana-plugin-server.metricsservicesetup.getopsmetrics_.md new file mode 100644 index 000000000000000..454b8c905451ee0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.metricsservicesetup.getopsmetrics_.md @@ -0,0 +1,24 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [MetricsServiceSetup](./kibana-plugin-server.metricsservicesetup.md) > [getOpsMetrics$](./kibana-plugin-server.metricsservicesetup.getopsmetrics_.md) + +## MetricsServiceSetup.getOpsMetrics$ property + +Retrieve an observable emitting the [OpsMetrics](./kibana-plugin-server.opsmetrics.md) gathered. The observable will emit an initial value during core's `start` phase, and a new value every fixed interval of time, based on the `opts.interval` configuration property. + +<b>Signature:</b> + +```typescript +getOpsMetrics$: () => Observable<OpsMetrics>; +``` + +## Example + + +```ts +core.metrics.getOpsMetrics$().subscribe(metrics => { + // do something with the metrics +}) + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.metricsservicesetup.md b/docs/development/core/server/kibana-plugin-server.metricsservicesetup.md new file mode 100644 index 000000000000000..270c56402a390da --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.metricsservicesetup.md @@ -0,0 +1,20 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [MetricsServiceSetup](./kibana-plugin-server.metricsservicesetup.md) + +## MetricsServiceSetup interface + +APIs to retrieves metrics gathered and exposed by the core platform. + +<b>Signature:</b> + +```typescript +export interface MetricsServiceSetup +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [getOpsMetrics$](./kibana-plugin-server.metricsservicesetup.getopsmetrics_.md) | <code>() => Observable<OpsMetrics></code> | Retrieve an observable emitting the [OpsMetrics](./kibana-plugin-server.opsmetrics.md) gathered. The observable will emit an initial value during core's <code>start</code> phase, and a new value every fixed interval of time, based on the <code>opts.interval</code> configuration property. | + diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.concurrent_connections.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.concurrent_connections.md new file mode 100644 index 000000000000000..cfd39a551ad349a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.concurrent_connections.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [concurrent\_connections](./kibana-plugin-server.opsmetrics.concurrent_connections.md) + +## OpsMetrics.concurrent\_connections property + +number of current concurrent connections to the server + +<b>Signature:</b> + +```typescript +concurrent_connections: OpsServerMetrics['concurrent_connections']; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.md new file mode 100644 index 000000000000000..e23bd8d431d3fbc --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.md @@ -0,0 +1,24 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) + +## OpsMetrics interface + +Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. + +<b>Signature:</b> + +```typescript +export interface OpsMetrics +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [concurrent\_connections](./kibana-plugin-server.opsmetrics.concurrent_connections.md) | <code>OpsServerMetrics['concurrent_connections']</code> | number of current concurrent connections to the server | +| [os](./kibana-plugin-server.opsmetrics.os.md) | <code>OpsOsMetrics</code> | OS related metrics | +| [process](./kibana-plugin-server.opsmetrics.process.md) | <code>OpsProcessMetrics</code> | Process related metrics | +| [requests](./kibana-plugin-server.opsmetrics.requests.md) | <code>OpsServerMetrics['requests']</code> | server requests stats | +| [response\_times](./kibana-plugin-server.opsmetrics.response_times.md) | <code>OpsServerMetrics['response_times']</code> | server response time stats | + diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.os.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.os.md new file mode 100644 index 000000000000000..993a1d7a2d7b792 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.os.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [os](./kibana-plugin-server.opsmetrics.os.md) + +## OpsMetrics.os property + +OS related metrics + +<b>Signature:</b> + +```typescript +os: OpsOsMetrics; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.process.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.process.md new file mode 100644 index 000000000000000..53d3a33d66e06ad --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.process.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [process](./kibana-plugin-server.opsmetrics.process.md) + +## OpsMetrics.process property + +Process related metrics + +<b>Signature:</b> + +```typescript +process: OpsProcessMetrics; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.requests.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.requests.md new file mode 100644 index 000000000000000..9cd6b85e507f0c5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.requests.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [requests](./kibana-plugin-server.opsmetrics.requests.md) + +## OpsMetrics.requests property + +server requests stats + +<b>Signature:</b> + +```typescript +requests: OpsServerMetrics['requests']; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsmetrics.response_times.md b/docs/development/core/server/kibana-plugin-server.opsmetrics.response_times.md new file mode 100644 index 000000000000000..358699071b1c3bf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsmetrics.response_times.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsMetrics](./kibana-plugin-server.opsmetrics.md) > [response\_times](./kibana-plugin-server.opsmetrics.response_times.md) + +## OpsMetrics.response\_times property + +server response time stats + +<b>Signature:</b> + +```typescript +response_times: OpsServerMetrics['response_times']; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.distro.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.distro.md new file mode 100644 index 000000000000000..338164f173d025c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.distro.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [distro](./kibana-plugin-server.opsosmetrics.distro.md) + +## OpsOsMetrics.distro property + +The os distrib. Only present for linux platforms + +<b>Signature:</b> + +```typescript +distro?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.distrorelease.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.distrorelease.md new file mode 100644 index 000000000000000..24c5a1f00b64c9c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.distrorelease.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [distroRelease](./kibana-plugin-server.opsosmetrics.distrorelease.md) + +## OpsOsMetrics.distroRelease property + +The os distrib release, prefixed by the os distrib. Only present for linux platforms + +<b>Signature:</b> + +```typescript +distroRelease?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.load.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.load.md new file mode 100644 index 000000000000000..0bf17502ce34e46 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.load.md @@ -0,0 +1,17 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [load](./kibana-plugin-server.opsosmetrics.load.md) + +## OpsOsMetrics.load property + +cpu load metrics + +<b>Signature:</b> + +```typescript +load: { + '1m': number; + '5m': number; + '15m': number; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.md new file mode 100644 index 000000000000000..0fb4e59fdf539a8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.md @@ -0,0 +1,26 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) + +## OpsOsMetrics interface + +OS related metrics + +<b>Signature:</b> + +```typescript +export interface OpsOsMetrics +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [distro](./kibana-plugin-server.opsosmetrics.distro.md) | <code>string</code> | The os distrib. Only present for linux platforms | +| [distroRelease](./kibana-plugin-server.opsosmetrics.distrorelease.md) | <code>string</code> | The os distrib release, prefixed by the os distrib. Only present for linux platforms | +| [load](./kibana-plugin-server.opsosmetrics.load.md) | <code>{</code><br/><code> '1m': number;</code><br/><code> '5m': number;</code><br/><code> '15m': number;</code><br/><code> }</code> | cpu load metrics | +| [memory](./kibana-plugin-server.opsosmetrics.memory.md) | <code>{</code><br/><code> total_in_bytes: number;</code><br/><code> free_in_bytes: number;</code><br/><code> used_in_bytes: number;</code><br/><code> }</code> | system memory usage metrics | +| [platform](./kibana-plugin-server.opsosmetrics.platform.md) | <code>NodeJS.Platform</code> | The os platform | +| [platformRelease](./kibana-plugin-server.opsosmetrics.platformrelease.md) | <code>string</code> | The os platform release, prefixed by the platform name | +| [uptime\_in\_millis](./kibana-plugin-server.opsosmetrics.uptime_in_millis.md) | <code>number</code> | the OS uptime | + diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.memory.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.memory.md new file mode 100644 index 000000000000000..4a1becaeeaec711 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.memory.md @@ -0,0 +1,17 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [memory](./kibana-plugin-server.opsosmetrics.memory.md) + +## OpsOsMetrics.memory property + +system memory usage metrics + +<b>Signature:</b> + +```typescript +memory: { + total_in_bytes: number; + free_in_bytes: number; + used_in_bytes: number; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.platform.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.platform.md new file mode 100644 index 000000000000000..411d0fc546dc0be --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.platform.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [platform](./kibana-plugin-server.opsosmetrics.platform.md) + +## OpsOsMetrics.platform property + +The os platform + +<b>Signature:</b> + +```typescript +platform: NodeJS.Platform; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.platformrelease.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.platformrelease.md new file mode 100644 index 000000000000000..1071b4a38f58806 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.platformrelease.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [platformRelease](./kibana-plugin-server.opsosmetrics.platformrelease.md) + +## OpsOsMetrics.platformRelease property + +The os platform release, prefixed by the platform name + +<b>Signature:</b> + +```typescript +platformRelease: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsosmetrics.uptime_in_millis.md b/docs/development/core/server/kibana-plugin-server.opsosmetrics.uptime_in_millis.md new file mode 100644 index 000000000000000..dfff1a1f1da0baf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsosmetrics.uptime_in_millis.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsOsMetrics](./kibana-plugin-server.opsosmetrics.md) > [uptime\_in\_millis](./kibana-plugin-server.opsosmetrics.uptime_in_millis.md) + +## OpsOsMetrics.uptime\_in\_millis property + +the OS uptime + +<b>Signature:</b> + +```typescript +uptime_in_millis: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.event_loop_delay.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.event_loop_delay.md new file mode 100644 index 000000000000000..f61c8b0995324d5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.event_loop_delay.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) > [event\_loop\_delay](./kibana-plugin-server.opsprocessmetrics.event_loop_delay.md) + +## OpsProcessMetrics.event\_loop\_delay property + +node event loop delay + +<b>Signature:</b> + +```typescript +event_loop_delay: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.md new file mode 100644 index 000000000000000..92fd8471cce7dad --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.md @@ -0,0 +1,23 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) + +## OpsProcessMetrics interface + +Process related metrics + +<b>Signature:</b> + +```typescript +export interface OpsProcessMetrics +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [event\_loop\_delay](./kibana-plugin-server.opsprocessmetrics.event_loop_delay.md) | <code>number</code> | node event loop delay | +| [memory](./kibana-plugin-server.opsprocessmetrics.memory.md) | <code>{</code><br/><code> heap: {</code><br/><code> total_in_bytes: number;</code><br/><code> used_in_bytes: number;</code><br/><code> size_limit: number;</code><br/><code> };</code><br/><code> resident_set_size_in_bytes: number;</code><br/><code> }</code> | process memory usage | +| [pid](./kibana-plugin-server.opsprocessmetrics.pid.md) | <code>number</code> | pid of the kibana process | +| [uptime\_in\_millis](./kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md) | <code>number</code> | uptime of the kibana process | + diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.memory.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.memory.md new file mode 100644 index 000000000000000..5c1a8de70dc0197 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.memory.md @@ -0,0 +1,20 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) > [memory](./kibana-plugin-server.opsprocessmetrics.memory.md) + +## OpsProcessMetrics.memory property + +process memory usage + +<b>Signature:</b> + +```typescript +memory: { + heap: { + total_in_bytes: number; + used_in_bytes: number; + size_limit: number; + }; + resident_set_size_in_bytes: number; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.pid.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.pid.md new file mode 100644 index 000000000000000..a34187f3720188f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.pid.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) > [pid](./kibana-plugin-server.opsprocessmetrics.pid.md) + +## OpsProcessMetrics.pid property + +pid of the kibana process + +<b>Signature:</b> + +```typescript +pid: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md new file mode 100644 index 000000000000000..24db2f017a663eb --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsProcessMetrics](./kibana-plugin-server.opsprocessmetrics.md) > [uptime\_in\_millis](./kibana-plugin-server.opsprocessmetrics.uptime_in_millis.md) + +## OpsProcessMetrics.uptime\_in\_millis property + +uptime of the kibana process + +<b>Signature:</b> + +```typescript +uptime_in_millis: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsservermetrics.concurrent_connections.md b/docs/development/core/server/kibana-plugin-server.opsservermetrics.concurrent_connections.md new file mode 100644 index 000000000000000..ade79fedfa1b5c4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsservermetrics.concurrent_connections.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) > [concurrent\_connections](./kibana-plugin-server.opsservermetrics.concurrent_connections.md) + +## OpsServerMetrics.concurrent\_connections property + +number of current concurrent connections to the server + +<b>Signature:</b> + +```typescript +concurrent_connections: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsservermetrics.md b/docs/development/core/server/kibana-plugin-server.opsservermetrics.md new file mode 100644 index 000000000000000..4e35c02bd9f28ae --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsservermetrics.md @@ -0,0 +1,22 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) + +## OpsServerMetrics interface + +server related metrics + +<b>Signature:</b> + +```typescript +export interface OpsServerMetrics +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [concurrent\_connections](./kibana-plugin-server.opsservermetrics.concurrent_connections.md) | <code>number</code> | number of current concurrent connections to the server | +| [requests](./kibana-plugin-server.opsservermetrics.requests.md) | <code>{</code><br/><code> disconnects: number;</code><br/><code> total: number;</code><br/><code> statusCodes: Record<number, number>;</code><br/><code> }</code> | server requests stats | +| [response\_times](./kibana-plugin-server.opsservermetrics.response_times.md) | <code>{</code><br/><code> avg_in_millis: number;</code><br/><code> max_in_millis: number;</code><br/><code> }</code> | server response time stats | + diff --git a/docs/development/core/server/kibana-plugin-server.opsservermetrics.requests.md b/docs/development/core/server/kibana-plugin-server.opsservermetrics.requests.md new file mode 100644 index 000000000000000..5ad2abc8695577c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsservermetrics.requests.md @@ -0,0 +1,17 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) > [requests](./kibana-plugin-server.opsservermetrics.requests.md) + +## OpsServerMetrics.requests property + +server requests stats + +<b>Signature:</b> + +```typescript +requests: { + disconnects: number; + total: number; + statusCodes: Record<number, number>; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.opsservermetrics.response_times.md b/docs/development/core/server/kibana-plugin-server.opsservermetrics.response_times.md new file mode 100644 index 000000000000000..5008efc6ad4da21 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.opsservermetrics.response_times.md @@ -0,0 +1,16 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [OpsServerMetrics](./kibana-plugin-server.opsservermetrics.md) > [response\_times](./kibana-plugin-server.opsservermetrics.response_times.md) + +## OpsServerMetrics.response\_times property + +server response time stats + +<b>Signature:</b> + +```typescript +response_times: { + avg_in_millis: number; + max_in_millis: number; + }; +``` diff --git a/package.json b/package.json index e727d87a83c537a..2c401724c72cd0b 100644 --- a/package.json +++ b/package.json @@ -323,6 +323,7 @@ "@types/fetch-mock": "^7.3.1", "@types/flot": "^0.0.31", "@types/getopts": "^2.0.1", + "@types/getos": "^3.0.0", "@types/glob": "^7.1.1", "@types/globby": "^8.0.0", "@types/graphql": "^0.13.2", diff --git a/src/core/server/index.ts b/src/core/server/index.ts index e45d4f28edcc37c..de6cdb2d7acd78f 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -245,6 +245,14 @@ export { StringValidationRegexString, } from './ui_settings'; +export { + OpsMetrics, + OpsOsMetrics, + OpsServerMetrics, + OpsProcessMetrics, + MetricsServiceSetup, +} from './metrics'; + export { RecursiveReadonly } from '../utils'; export { diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index ff68d1544d119ee..37d1061dc618dcb 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -30,6 +30,7 @@ import { } from './saved_objects'; import { InternalUiSettingsServiceSetup, InternalUiSettingsServiceStart } from './ui_settings'; import { UuidServiceSetup } from './uuid'; +import { InternalMetricsServiceSetup } from './metrics'; /** @internal */ export interface InternalCoreSetup { @@ -40,6 +41,7 @@ export interface InternalCoreSetup { uiSettings: InternalUiSettingsServiceSetup; savedObjects: InternalSavedObjectsServiceSetup; uuid: UuidServiceSetup; + metrics: InternalMetricsServiceSetup; } /** diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 46436461505c068..50468db8a504de4 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -43,6 +43,7 @@ import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service. import { capabilitiesServiceMock } from '../capabilities/capabilities_service.mock'; import { setupMock as renderingServiceMock } from '../rendering/__mocks__/rendering_service'; import { uuidServiceMock } from '../uuid/uuid_service.mock'; +import { metricsServiceMock } from '../metrics/metrics_service.mock'; import { findLegacyPluginSpecs } from './plugins'; import { LegacyVars, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './types'; import { LegacyService } from './legacy_service'; @@ -93,6 +94,7 @@ beforeEach(() => { }, }, rendering: renderingServiceMock, + metrics: metricsServiceMock.createInternalSetupContract(), uuid: uuidSetup, }, plugins: { 'plugin-id': 'plugin-value' }, diff --git a/src/core/server/metrics/collectors/index.ts b/src/core/server/metrics/collectors/index.ts new file mode 100644 index 000000000000000..f58ab02e638813d --- /dev/null +++ b/src/core/server/metrics/collectors/index.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { OpsProcessMetrics, OpsOsMetrics, OpsServerMetrics, MetricsCollector } from './types'; +export { OsMetricsCollector } from './os'; +export { ProcessMetricsCollector } from './process'; +export { ServerMetricsCollector } from './server'; diff --git a/src/core/server/metrics/collectors/os.test.ts b/src/core/server/metrics/collectors/os.test.ts new file mode 100644 index 000000000000000..7d5a6da90b7d625 --- /dev/null +++ b/src/core/server/metrics/collectors/os.test.ts @@ -0,0 +1,99 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('getos', () => (cb: Function) => cb(null, { dist: 'distrib', release: 'release' })); + +import os from 'os'; +import { OsMetricsCollector } from './os'; + +describe('OsMetricsCollector', () => { + let collector: OsMetricsCollector; + + beforeEach(() => { + collector = new OsMetricsCollector(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('collects platform info from the os package', async () => { + const platform = 'darwin'; + const release = '10.14.1'; + + jest.spyOn(os, 'platform').mockImplementation(() => platform); + jest.spyOn(os, 'release').mockImplementation(() => release); + + const metrics = await collector.collect(); + + expect(metrics.platform).toBe(platform); + expect(metrics.platformRelease).toBe(`${platform}-${release}`); + }); + + it('collects distribution info when platform is linux', async () => { + const platform = 'linux'; + + jest.spyOn(os, 'platform').mockImplementation(() => platform); + + const metrics = await collector.collect(); + + expect(metrics.distro).toBe('distrib'); + expect(metrics.distroRelease).toBe('distrib-release'); + }); + + it('collects memory info from the os package', async () => { + const totalMemory = 1457886; + const freeMemory = 456786; + + jest.spyOn(os, 'totalmem').mockImplementation(() => totalMemory); + jest.spyOn(os, 'freemem').mockImplementation(() => freeMemory); + + const metrics = await collector.collect(); + + expect(metrics.memory.total_in_bytes).toBe(totalMemory); + expect(metrics.memory.free_in_bytes).toBe(freeMemory); + expect(metrics.memory.used_in_bytes).toBe(totalMemory - freeMemory); + }); + + it('collects uptime info from the os package', async () => { + const uptime = 325; + + jest.spyOn(os, 'uptime').mockImplementation(() => uptime); + + const metrics = await collector.collect(); + + expect(metrics.uptime_in_millis).toBe(uptime * 1000); + }); + + it('collects load info from the os package', async () => { + const oneMinLoad = 1; + const fiveMinLoad = 2; + const fifteenMinLoad = 3; + + jest.spyOn(os, 'loadavg').mockImplementation(() => [oneMinLoad, fiveMinLoad, fifteenMinLoad]); + + const metrics = await collector.collect(); + + expect(metrics.load).toEqual({ + '1m': oneMinLoad, + '5m': fiveMinLoad, + '15m': fifteenMinLoad, + }); + }); +}); diff --git a/src/core/server/metrics/collectors/os.ts b/src/core/server/metrics/collectors/os.ts new file mode 100644 index 000000000000000..d3d9bb0be86fa2a --- /dev/null +++ b/src/core/server/metrics/collectors/os.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import os from 'os'; +import getosAsync, { LinuxOs } from 'getos'; +import { promisify } from 'util'; +import { OpsOsMetrics, MetricsCollector } from './types'; + +const getos = promisify(getosAsync); + +export class OsMetricsCollector implements MetricsCollector<OpsOsMetrics> { + public async collect(): Promise<OpsOsMetrics> { + const platform = os.platform(); + const load = os.loadavg(); + + const metrics: OpsOsMetrics = { + platform, + platformRelease: `${platform}-${os.release()}`, + load: { + '1m': load[0], + '5m': load[1], + '15m': load[2], + }, + memory: { + total_in_bytes: os.totalmem(), + free_in_bytes: os.freemem(), + used_in_bytes: os.totalmem() - os.freemem(), + }, + uptime_in_millis: os.uptime() * 1000, + }; + + if (platform === 'linux') { + try { + const distro = (await getos()) as LinuxOs; + metrics.distro = distro.dist; + metrics.distroRelease = `${distro.dist}-${distro.release}`; + } catch (e) { + // ignore errors + } + } + + return metrics; + } +} diff --git a/src/core/server/metrics/collectors/process.test.ts b/src/core/server/metrics/collectors/process.test.ts new file mode 100644 index 000000000000000..a437d799371f112 --- /dev/null +++ b/src/core/server/metrics/collectors/process.test.ts @@ -0,0 +1,81 @@ +/* + * 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 v8, { HeapInfo } from 'v8'; +import { ProcessMetricsCollector } from './process'; + +describe('ProcessMetricsCollector', () => { + let collector: ProcessMetricsCollector; + + beforeEach(() => { + collector = new ProcessMetricsCollector(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('collects pid from the process', async () => { + const metrics = await collector.collect(); + + expect(metrics.pid).toEqual(process.pid); + }); + + it('collects event loop delay', async () => { + const metrics = await collector.collect(); + + expect(metrics.event_loop_delay).toBeGreaterThan(0); + }); + + it('collects uptime info from the process', async () => { + const uptime = 58986; + jest.spyOn(process, 'uptime').mockImplementation(() => uptime); + + const metrics = await collector.collect(); + + expect(metrics.uptime_in_millis).toEqual(uptime * 1000); + }); + + it('collects memory info from the process', async () => { + const heapTotal = 58986; + const heapUsed = 4688; + const heapSizeLimit = 5788; + const rss = 5865; + jest.spyOn(process, 'memoryUsage').mockImplementation(() => ({ + rss, + heapTotal, + heapUsed, + external: 0, + })); + + jest.spyOn(v8, 'getHeapStatistics').mockImplementation( + () => + ({ + heap_size_limit: heapSizeLimit, + } as HeapInfo) + ); + + const metrics = await collector.collect(); + + expect(metrics.memory.heap.total_in_bytes).toEqual(heapTotal); + expect(metrics.memory.heap.used_in_bytes).toEqual(heapUsed); + expect(metrics.memory.heap.size_limit).toEqual(heapSizeLimit); + expect(metrics.memory.resident_set_size_in_bytes).toEqual(rss); + }); +}); diff --git a/src/core/server/metrics/collectors/process.ts b/src/core/server/metrics/collectors/process.ts new file mode 100644 index 000000000000000..aa68abaf74e41a9 --- /dev/null +++ b/src/core/server/metrics/collectors/process.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import v8 from 'v8'; +import { Bench } from 'hoek'; +import { OpsProcessMetrics, MetricsCollector } from './types'; + +export class ProcessMetricsCollector implements MetricsCollector<OpsProcessMetrics> { + public async collect(): Promise<OpsProcessMetrics> { + const heapStats = v8.getHeapStatistics(); + const memoryUsage = process.memoryUsage(); + const [eventLoopDelay] = await Promise.all([getEventLoopDelay()]); + return { + memory: { + heap: { + total_in_bytes: memoryUsage.heapTotal, + used_in_bytes: memoryUsage.heapUsed, + size_limit: heapStats.heap_size_limit, + }, + resident_set_size_in_bytes: memoryUsage.rss, + }, + pid: process.pid, + event_loop_delay: eventLoopDelay, + uptime_in_millis: process.uptime() * 1000, + }; + } +} + +const getEventLoopDelay = (): Promise<number> => { + const bench = new Bench(); + return new Promise(resolve => { + setImmediate(() => { + return resolve(bench.elapsed()); + }); + }); +}; diff --git a/src/core/server/metrics/collectors/server.ts b/src/core/server/metrics/collectors/server.ts new file mode 100644 index 000000000000000..e46ac2f653df664 --- /dev/null +++ b/src/core/server/metrics/collectors/server.ts @@ -0,0 +1,80 @@ +/* + * 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 { ResponseObject, Server as HapiServer } from 'hapi'; +import { OpsServerMetrics, MetricsCollector } from './types'; + +interface ServerResponseTime { + count: number; + total: number; + max: number; +} + +export class ServerMetricsCollector implements MetricsCollector<OpsServerMetrics> { + private readonly requests: OpsServerMetrics['requests'] = { + disconnects: 0, + total: 0, + statusCodes: {}, + }; + private readonly responseTimes: ServerResponseTime = { + count: 0, + total: 0, + max: 0, + }; + + constructor(private readonly server: HapiServer) { + this.server.ext('onRequest', (request, h) => { + this.requests.total++; + request.events.once('disconnect', () => { + this.requests.disconnects++; + }); + return h.continue; + }); + this.server.events.on('response', request => { + const statusCode = (request.response as ResponseObject)?.statusCode; + if (statusCode) { + if (!this.requests.statusCodes[statusCode]) { + this.requests.statusCodes[statusCode] = 0; + } + this.requests.statusCodes[statusCode]++; + } + + const duration = Date.now() - request.info.received; + this.responseTimes.count++; + this.responseTimes.total += duration; + this.responseTimes.max = Math.max(this.responseTimes.max, duration); + }); + } + + public async collect(): Promise<OpsServerMetrics> { + const connections = await new Promise<number>(resolve => { + this.server.listener.getConnections((_, count) => { + resolve(count); + }); + }); + + return { + requests: this.requests, + response_times: { + avg_in_millis: this.responseTimes.total / Math.max(this.responseTimes.count, 1), + max_in_millis: this.responseTimes.max, + }, + concurrent_connections: connections, + }; + } +} diff --git a/src/core/server/metrics/collectors/types.ts b/src/core/server/metrics/collectors/types.ts new file mode 100644 index 000000000000000..5a83bc70af3c111 --- /dev/null +++ b/src/core/server/metrics/collectors/types.ts @@ -0,0 +1,110 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** Base interface for all metrics gatherers */ +export interface MetricsCollector<T> { + collect(): Promise<T>; +} + +/** + * Process related metrics + * @public + */ +export interface OpsProcessMetrics { + /** process memory usage */ + memory: { + /** heap memory usage */ + heap: { + /** total heap available */ + total_in_bytes: number; + /** used heap */ + used_in_bytes: number; + /** v8 heap size limit */ + size_limit: number; + }; + /** node rss */ + resident_set_size_in_bytes: number; + }; + /** node event loop delay */ + event_loop_delay: number; + /** pid of the kibana process */ + pid: number; + /** uptime of the kibana process */ + uptime_in_millis: number; +} + +/** + * OS related metrics + * @public + */ +export interface OpsOsMetrics { + /** The os platform */ + platform: NodeJS.Platform; + /** The os platform release, prefixed by the platform name */ + platformRelease: string; + /** The os distrib. Only present for linux platforms */ + distro?: string; + /** The os distrib release, prefixed by the os distrib. Only present for linux platforms */ + distroRelease?: string; + /** cpu load metrics */ + load: { + /** load for last minute */ + '1m': number; + /** load for last 5 minutes */ + '5m': number; + /** load for last 15 minutes */ + '15m': number; + }; + /** system memory usage metrics */ + memory: { + /** total memory available */ + total_in_bytes: number; + /** current free memory */ + free_in_bytes: number; + /** current used memory */ + used_in_bytes: number; + }; + /** the OS uptime */ + uptime_in_millis: number; +} + +/** + * server related metrics + * @public + */ +export interface OpsServerMetrics { + /** server response time stats */ + response_times: { + /** average response time */ + avg_in_millis: number; + /** maximum response time */ + max_in_millis: number; + }; + /** server requests stats */ + requests: { + /** number of disconnected requests since startup */ + disconnects: number; + /** total number of requests handled since startup */ + total: number; + /** number of request handled per response status code */ + statusCodes: Record<number, number>; + }; + /** number of current concurrent connections to the server */ + concurrent_connections: number; +} diff --git a/src/core/server/metrics/index.ts b/src/core/server/metrics/index.ts new file mode 100644 index 000000000000000..fdcf637c0cd7b9d --- /dev/null +++ b/src/core/server/metrics/index.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + InternalMetricsServiceStart, + InternalMetricsServiceSetup, + MetricsServiceSetup, + MetricsServiceStart, + OpsMetrics, +} from './types'; +export { OpsProcessMetrics, OpsServerMetrics, OpsOsMetrics } from './collectors'; +export { MetricsService } from './metrics_service'; +export { opsConfig } from './ops_config'; diff --git a/src/core/server/metrics/integration_tests/server_collector.test.ts b/src/core/server/metrics/integration_tests/server_collector.test.ts new file mode 100644 index 000000000000000..a387de80212d91b --- /dev/null +++ b/src/core/server/metrics/integration_tests/server_collector.test.ts @@ -0,0 +1,183 @@ +/* + * 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 { Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; +import supertest from 'supertest'; +import { Server as HapiServer } from 'hapi'; +import { createHttpServer } from '../../http/test_utils'; +import { HttpService, IRouter } from '../../http'; +import { contextServiceMock } from '../../context/context_service.mock'; +import { ServerMetricsCollector } from '../collectors/server'; + +describe('ServerMetricsCollector', () => { + let server: HttpService; + let collector: ServerMetricsCollector; + let hapiServer: HapiServer; + let router: IRouter; + + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + const sendGet = (path: string) => supertest(hapiServer.listener).get(path); + + beforeEach(async () => { + server = createHttpServer(); + const contextSetup = contextServiceMock.createSetupContract(); + const httpSetup = await server.setup({ context: contextSetup }); + hapiServer = httpSetup.server; + router = httpSetup.createRouter('/'); + collector = new ServerMetricsCollector(hapiServer); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('collect requests infos', async () => { + router.get({ path: '/', validate: false }, async (ctx, req, res) => { + return res.ok({ body: '' }); + }); + await server.start(); + + let metrics = await collector.collect(); + + expect(metrics.requests).toEqual({ + total: 0, + disconnects: 0, + statusCodes: {}, + }); + + await sendGet('/'); + await sendGet('/'); + await sendGet('/not-found'); + + metrics = await collector.collect(); + + expect(metrics.requests).toEqual({ + total: 3, + disconnects: 0, + statusCodes: { + '200': 2, + '404': 1, + }, + }); + }); + + it('collect disconnects requests infos', async () => { + const never = new Promise(resolve => undefined); + + router.get({ path: '/', validate: false }, async (ctx, req, res) => { + return res.ok({ body: '' }); + }); + router.get({ path: '/disconnect', validate: false }, async (ctx, req, res) => { + await never; + return res.ok({ body: '' }); + }); + await server.start(); + + await sendGet('/'); + const discoReq1 = sendGet('/disconnect').end(); + const discoReq2 = sendGet('/disconnect').end(); + await delay(20); + + let metrics = await collector.collect(); + expect(metrics.requests).toEqual( + expect.objectContaining({ + total: 3, + disconnects: 0, + }) + ); + + discoReq1.abort(); + await delay(20); + + metrics = await collector.collect(); + expect(metrics.requests).toEqual( + expect.objectContaining({ + total: 3, + disconnects: 1, + }) + ); + + discoReq2.abort(); + await delay(20); + + metrics = await collector.collect(); + expect(metrics.requests).toEqual( + expect.objectContaining({ + total: 3, + disconnects: 2, + }) + ); + }); + + it('collect response times', async () => { + router.get({ path: '/no-delay', validate: false }, async (ctx, req, res) => { + return res.ok({ body: '' }); + }); + router.get({ path: '/500-ms', validate: false }, async (ctx, req, res) => { + await delay(500); + return res.ok({ body: '' }); + }); + router.get({ path: '/250-ms', validate: false }, async (ctx, req, res) => { + await delay(250); + return res.ok({ body: '' }); + }); + await server.start(); + + await Promise.all([sendGet('/no-delay'), sendGet('/250-ms')]); + let metrics = await collector.collect(); + + expect(metrics.response_times.avg_in_millis).toBeGreaterThanOrEqual(125); + expect(metrics.response_times.max_in_millis).toBeGreaterThanOrEqual(250); + + await Promise.all([sendGet('/500-ms'), sendGet('/500-ms')]); + metrics = await collector.collect(); + + expect(metrics.response_times.avg_in_millis).toBeGreaterThanOrEqual(250); + expect(metrics.response_times.max_in_millis).toBeGreaterThanOrEqual(500); + }); + + it('collect connection count', async () => { + const waitSubject = new Subject(); + + router.get({ path: '/', validate: false }, async (ctx, req, res) => { + await waitSubject.pipe(take(1)).toPromise(); + return res.ok({ body: '' }); + }); + await server.start(); + + let metrics = await collector.collect(); + expect(metrics.concurrent_connections).toEqual(0); + + sendGet('/').end(() => null); + await delay(20); + metrics = await collector.collect(); + expect(metrics.concurrent_connections).toEqual(1); + + sendGet('/').end(() => null); + await delay(20); + metrics = await collector.collect(); + expect(metrics.concurrent_connections).toEqual(2); + + waitSubject.next('go'); + await delay(20); + metrics = await collector.collect(); + expect(metrics.concurrent_connections).toEqual(0); + }); +}); diff --git a/src/core/server/metrics/metrics_service.mock.ts b/src/core/server/metrics/metrics_service.mock.ts new file mode 100644 index 000000000000000..cc53a4e27d5710a --- /dev/null +++ b/src/core/server/metrics/metrics_service.mock.ts @@ -0,0 +1,67 @@ +/* + * 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 { MetricsService } from './metrics_service'; +import { + InternalMetricsServiceSetup, + InternalMetricsServiceStart, + MetricsServiceSetup, + MetricsServiceStart, +} from './types'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked<MetricsServiceSetup> = { + getOpsMetrics$: jest.fn(), + }; + return setupContract; +}; + +const createInternalSetupContractMock = () => { + const setupContract: jest.Mocked<InternalMetricsServiceSetup> = createSetupContractMock(); + return setupContract; +}; + +const createStartContractMock = () => { + const startContract: jest.Mocked<MetricsServiceStart> = {}; + return startContract; +}; + +const createInternalStartContractMock = () => { + const startContract: jest.Mocked<InternalMetricsServiceStart> = createStartContractMock(); + return startContract; +}; + +type MetricsServiceContract = PublicMethodsOf<MetricsService>; + +const createMock = () => { + const mocked: jest.Mocked<MetricsServiceContract> = { + setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), + start: jest.fn().mockReturnValue(createInternalStartContractMock()), + stop: jest.fn(), + }; + return mocked; +}; + +export const metricsServiceMock = { + create: createMock, + createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, + createInternalSetupContract: createInternalSetupContractMock, + createInternalStartContract: createInternalStartContractMock, +}; diff --git a/src/core/server/metrics/metrics_service.test.mocks.ts b/src/core/server/metrics/metrics_service.test.mocks.ts new file mode 100644 index 000000000000000..8e91775283042b1 --- /dev/null +++ b/src/core/server/metrics/metrics_service.test.mocks.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const mockOpsCollector = { + collect: jest.fn(), +}; +jest.doMock('./ops_metrics_collector', () => ({ + OpsMetricsCollector: jest.fn().mockImplementation(() => mockOpsCollector), +})); diff --git a/src/core/server/metrics/metrics_service.test.ts b/src/core/server/metrics/metrics_service.test.ts new file mode 100644 index 000000000000000..10d6761adbe7d5e --- /dev/null +++ b/src/core/server/metrics/metrics_service.test.ts @@ -0,0 +1,134 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; +import { mockOpsCollector } from './metrics_service.test.mocks'; +import { MetricsService } from './metrics_service'; +import { mockCoreContext } from '../core_context.mock'; +import { configServiceMock } from '../config/config_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { take } from 'rxjs/operators'; + +const testInterval = 100; + +const dummyMetrics = { metricA: 'value', metricB: 'otherValue' }; + +describe('MetricsService', () => { + const httpMock = httpServiceMock.createSetupContract(); + let metricsService: MetricsService; + + beforeEach(() => { + jest.useFakeTimers(); + + const configService = configServiceMock.create({ + atPath: { interval: moment.duration(testInterval) }, + }); + const coreContext = mockCoreContext.create({ configService }); + metricsService = new MetricsService(coreContext); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + describe('#start', () => { + it('invokes setInterval with the configured interval', async () => { + await metricsService.setup({ http: httpMock }); + await metricsService.start(); + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenCalledWith(expect.any(Function), testInterval); + }); + + it('emits the metrics at start', async () => { + mockOpsCollector.collect.mockResolvedValue(dummyMetrics); + + const { getOpsMetrics$ } = await metricsService.setup({ + http: httpMock, + }); + + await metricsService.start(); + + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(1); + expect( + await getOpsMetrics$() + .pipe(take(1)) + .toPromise() + ).toEqual(dummyMetrics); + }); + + it('collects the metrics at every interval', async () => { + mockOpsCollector.collect.mockResolvedValue(dummyMetrics); + + await metricsService.setup({ http: httpMock }); + + await metricsService.start(); + + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(testInterval); + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(2); + + jest.advanceTimersByTime(testInterval); + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(3); + }); + + it('throws when called before setup', async () => { + await expect(metricsService.start()).rejects.toThrowErrorMatchingInlineSnapshot( + `"#setup() needs to be run first"` + ); + }); + }); + + describe('#stop', () => { + it('stops the metrics interval', async () => { + const { getOpsMetrics$ } = await metricsService.setup({ http: httpMock }); + await metricsService.start(); + + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(testInterval); + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(2); + + await metricsService.stop(); + jest.advanceTimersByTime(10 * testInterval); + expect(mockOpsCollector.collect).toHaveBeenCalledTimes(2); + + getOpsMetrics$().subscribe({ complete: () => {} }); + }); + + it('completes the metrics observable', async () => { + const { getOpsMetrics$ } = await metricsService.setup({ http: httpMock }); + await metricsService.start(); + + let completed = false; + + getOpsMetrics$().subscribe({ + complete: () => { + completed = true; + }, + }); + + await metricsService.stop(); + + expect(completed).toEqual(true); + }); + }); +}); diff --git a/src/core/server/metrics/metrics_service.ts b/src/core/server/metrics/metrics_service.ts new file mode 100644 index 000000000000000..1aed89a4aad6000 --- /dev/null +++ b/src/core/server/metrics/metrics_service.ts @@ -0,0 +1,86 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ReplaySubject } from 'rxjs'; +import { first, shareReplay } from 'rxjs/operators'; +import { CoreService } from '../../types'; +import { CoreContext } from '../core_context'; +import { Logger } from '../logging'; +import { InternalHttpServiceSetup } from '../http'; +import { InternalMetricsServiceSetup, InternalMetricsServiceStart, OpsMetrics } from './types'; +import { OpsMetricsCollector } from './ops_metrics_collector'; +import { opsConfig, OpsConfigType } from './ops_config'; + +interface MetricsServiceSetupDeps { + http: InternalHttpServiceSetup; +} + +/** @internal */ +export class MetricsService + implements CoreService<InternalMetricsServiceSetup, InternalMetricsServiceStart> { + private readonly logger: Logger; + private metricsCollector?: OpsMetricsCollector; + private collectInterval?: NodeJS.Timeout; + private metrics$ = new ReplaySubject<OpsMetrics>(1); + + constructor(private readonly coreContext: CoreContext) { + this.logger = coreContext.logger.get('metrics'); + } + + public async setup({ http }: MetricsServiceSetupDeps): Promise<InternalMetricsServiceSetup> { + this.metricsCollector = new OpsMetricsCollector(http.server); + + const metricsObservable = this.metrics$.pipe(shareReplay(1)); + + return { + getOpsMetrics$: () => metricsObservable, + }; + } + + public async start(): Promise<InternalMetricsServiceStart> { + if (!this.metricsCollector) { + throw new Error('#setup() needs to be run first'); + } + const config = await this.coreContext.configService + .atPath<OpsConfigType>(opsConfig.path) + .pipe(first()) + .toPromise(); + + await this.refreshMetrics(); + + this.collectInterval = setInterval(() => { + this.refreshMetrics(); + }, config.interval.asMilliseconds()); + + return {}; + } + + private async refreshMetrics() { + this.logger.debug('Refreshing metrics'); + const metrics = await this.metricsCollector!.collect(); + this.metrics$.next(metrics); + } + + public async stop() { + if (this.collectInterval) { + clearInterval(this.collectInterval); + } + this.metrics$.complete(); + } +} diff --git a/src/core/server/metrics/ops_config.ts b/src/core/server/metrics/ops_config.ts new file mode 100644 index 000000000000000..bd6ae5cc5474d77 --- /dev/null +++ b/src/core/server/metrics/ops_config.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const opsConfig = { + path: 'ops', + schema: schema.object({ + interval: schema.duration({ defaultValue: '5s' }), + }), +}; + +export type OpsConfigType = TypeOf<typeof opsConfig.schema>; diff --git a/src/core/server/metrics/ops_metrics_collector.test.mocks.ts b/src/core/server/metrics/ops_metrics_collector.test.mocks.ts new file mode 100644 index 000000000000000..8265796d57970ef --- /dev/null +++ b/src/core/server/metrics/ops_metrics_collector.test.mocks.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const mockOsCollector = { + collect: jest.fn(), +}; +jest.doMock('./collectors/os', () => ({ + OsMetricsCollector: jest.fn().mockImplementation(() => mockOsCollector), +})); + +export const mockProcessCollector = { + collect: jest.fn(), +}; +jest.doMock('./collectors/process', () => ({ + ProcessMetricsCollector: jest.fn().mockImplementation(() => mockProcessCollector), +})); + +export const mockServerCollector = { + collect: jest.fn(), +}; +jest.doMock('./collectors/server', () => ({ + ServerMetricsCollector: jest.fn().mockImplementation(() => mockServerCollector), +})); diff --git a/src/core/server/metrics/ops_metrics_collector.test.ts b/src/core/server/metrics/ops_metrics_collector.test.ts new file mode 100644 index 000000000000000..04302a195fb6cd3 --- /dev/null +++ b/src/core/server/metrics/ops_metrics_collector.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { + mockOsCollector, + mockProcessCollector, + mockServerCollector, +} from './ops_metrics_collector.test.mocks'; +import { httpServiceMock } from '../http/http_service.mock'; +import { OpsMetricsCollector } from './ops_metrics_collector'; + +describe('OpsMetricsCollector', () => { + let collector: OpsMetricsCollector; + + beforeEach(() => { + const hapiServer = httpServiceMock.createSetupContract().server; + collector = new OpsMetricsCollector(hapiServer); + + mockOsCollector.collect.mockResolvedValue('osMetrics'); + }); + + it('gathers metrics from the underlying collectors', async () => { + mockOsCollector.collect.mockResolvedValue('osMetrics'); + mockProcessCollector.collect.mockResolvedValue('processMetrics'); + mockServerCollector.collect.mockResolvedValue({ + requests: 'serverRequestsMetrics', + response_times: 'serverTimingMetrics', + }); + + const metrics = await collector.collect(); + + expect(mockOsCollector.collect).toHaveBeenCalledTimes(1); + expect(mockProcessCollector.collect).toHaveBeenCalledTimes(1); + expect(mockServerCollector.collect).toHaveBeenCalledTimes(1); + + expect(metrics).toEqual({ + process: 'processMetrics', + os: 'osMetrics', + requests: 'serverRequestsMetrics', + response_times: 'serverTimingMetrics', + }); + }); +}); diff --git a/src/core/server/metrics/ops_metrics_collector.ts b/src/core/server/metrics/ops_metrics_collector.ts new file mode 100644 index 000000000000000..04344f21f57f72d --- /dev/null +++ b/src/core/server/metrics/ops_metrics_collector.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Server as HapiServer } from 'hapi'; +import { + ProcessMetricsCollector, + OsMetricsCollector, + ServerMetricsCollector, + MetricsCollector, +} from './collectors'; +import { OpsMetrics } from './types'; + +export class OpsMetricsCollector implements MetricsCollector<OpsMetrics> { + private readonly processCollector: ProcessMetricsCollector; + private readonly osCollector: OsMetricsCollector; + private readonly serverCollector: ServerMetricsCollector; + + constructor(server: HapiServer) { + this.processCollector = new ProcessMetricsCollector(); + this.osCollector = new OsMetricsCollector(); + this.serverCollector = new ServerMetricsCollector(server); + } + + public async collect(): Promise<OpsMetrics> { + const [process, os, server] = await Promise.all([ + this.processCollector.collect(), + this.osCollector.collect(), + this.serverCollector.collect(), + ]); + return { + process, + os, + ...server, + }; + } +} diff --git a/src/core/server/metrics/types.ts b/src/core/server/metrics/types.ts new file mode 100644 index 000000000000000..5c8f18fff380de4 --- /dev/null +++ b/src/core/server/metrics/types.ts @@ -0,0 +1,66 @@ +/* + * 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 { Observable } from 'rxjs'; +import { OpsProcessMetrics, OpsOsMetrics, OpsServerMetrics } from './collectors'; + +/** + * APIs to retrieves metrics gathered and exposed by the core platform. + * + * @public + */ +export interface MetricsServiceSetup { + /** + * Retrieve an observable emitting the {@link OpsMetrics} gathered. + * The observable will emit an initial value during core's `start` phase, and a new value every fixed interval of time, + * based on the `opts.interval` configuration property. + * + * @example + * ```ts + * core.metrics.getOpsMetrics$().subscribe(metrics => { + * // do something with the metrics + * }) + * ``` + */ + getOpsMetrics$: () => Observable<OpsMetrics>; +} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface MetricsServiceStart {} + +export type InternalMetricsServiceSetup = MetricsServiceSetup; +export type InternalMetricsServiceStart = MetricsServiceStart; + +/** + * Regroups metrics gathered by all the collectors. + * This contains metrics about the os/runtime, the kibana process and the http server. + * + * @public + */ +export interface OpsMetrics { + /** Process related metrics */ + process: OpsProcessMetrics; + /** OS related metrics */ + os: OpsOsMetrics; + /** server response time stats */ + response_times: OpsServerMetrics['response_times']; + /** server requests stats */ + requests: OpsServerMetrics['requests']; + /** number of current concurrent connections to the server */ + concurrent_connections: OpsServerMetrics['concurrent_connections']; +} diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 96b28ab5827e1b9..037f3bbed67e06f 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -30,6 +30,8 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { SharedGlobalConfig } from './plugins'; import { InternalCoreSetup, InternalCoreStart } from './internal_types'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; +import { metricsServiceMock } from './metrics/metrics_service.mock'; +import { uuidServiceMock } from './uuid/uuid_service.mock'; export { httpServerMock } from './http/http_server.mocks'; export { sessionStorageMock } from './http/cookie_session_storage.mocks'; @@ -40,7 +42,7 @@ export { loggingServiceMock } from './logging/logging_service.mock'; export { savedObjectsRepositoryMock } from './saved_objects/service/lib/repository.mock'; export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_objects/saved_objects_type_registry.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; -import { uuidServiceMock } from './uuid/uuid_service.mock'; +export { metricsServiceMock } from './metrics/metrics_service.mock'; export function pluginInitializerContextConfigMock<T>(config: T) { const globalConfig: SharedGlobalConfig = { @@ -153,6 +155,7 @@ function createInternalCoreSetupMock() { uiSettings: uiSettingsServiceMock.createSetupContract(), savedObjects: savedObjectsServiceMock.createInternalSetupContract(), uuid: uuidServiceMock.createSetupContract(), + metrics: metricsServiceMock.createInternalSetupContract(), }; return setupDeps; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 42bc1ce214b1915..445ed16ec7829b0 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1176,6 +1176,11 @@ export interface LogRecord { timestamp: Date; } +// @public +export interface MetricsServiceSetup { + getOpsMetrics$: () => Observable<OpsMetrics>; +} + // @public (undocumented) export type MIGRATION_ASSISTANCE_INDEX_ACTION = 'upgrade' | 'reindex'; @@ -1227,6 +1232,63 @@ export interface OnPreResponseToolkit { next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; } +// @public +export interface OpsMetrics { + concurrent_connections: OpsServerMetrics['concurrent_connections']; + os: OpsOsMetrics; + process: OpsProcessMetrics; + requests: OpsServerMetrics['requests']; + response_times: OpsServerMetrics['response_times']; +} + +// @public +export interface OpsOsMetrics { + distro?: string; + distroRelease?: string; + load: { + '1m': number; + '5m': number; + '15m': number; + }; + memory: { + total_in_bytes: number; + free_in_bytes: number; + used_in_bytes: number; + }; + platform: NodeJS.Platform; + platformRelease: string; + uptime_in_millis: number; +} + +// @public +export interface OpsProcessMetrics { + event_loop_delay: number; + memory: { + heap: { + total_in_bytes: number; + used_in_bytes: number; + size_limit: number; + }; + resident_set_size_in_bytes: number; + }; + pid: number; + uptime_in_millis: number; +} + +// @public +export interface OpsServerMetrics { + concurrent_connections: number; + requests: { + disconnects: number; + total: number; + statusCodes: Record<number, number>; + }; + response_times: { + avg_in_millis: number; + max_in_millis: number; + }; +} + // @public (undocumented) export interface PackageInfo { // (undocumented) diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index 038c4651ff5a7dd..53d1b742a649443 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -79,3 +79,9 @@ export const mockUuidService = uuidServiceMock.create(); jest.doMock('./uuid/uuid_service', () => ({ UuidService: jest.fn(() => mockUuidService), })); + +import { metricsServiceMock } from './metrics/metrics_service.mock'; +export const mockMetricsService = metricsServiceMock.create(); +jest.doMock('./metrics/metrics_service', () => ({ + MetricsService: jest.fn(() => mockMetricsService), +})); diff --git a/src/core/server/server.test.ts b/src/core/server/server.test.ts index 161dd3759a218de..a4b5a9d81df203f 100644 --- a/src/core/server/server.test.ts +++ b/src/core/server/server.test.ts @@ -28,6 +28,7 @@ import { mockEnsureValidConfiguration, mockUiSettingsService, mockRenderingService, + mockMetricsService, } from './server.test.mocks'; import { BehaviorSubject } from 'rxjs'; @@ -61,6 +62,7 @@ test('sets up services on "setup"', async () => { expect(mockSavedObjectsService.setup).not.toHaveBeenCalled(); expect(mockUiSettingsService.setup).not.toHaveBeenCalled(); expect(mockRenderingService.setup).not.toHaveBeenCalled(); + expect(mockMetricsService.setup).not.toHaveBeenCalled(); await server.setup(); @@ -71,6 +73,7 @@ test('sets up services on "setup"', async () => { expect(mockSavedObjectsService.setup).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.setup).toHaveBeenCalledTimes(1); expect(mockRenderingService.setup).toHaveBeenCalledTimes(1); + expect(mockMetricsService.setup).toHaveBeenCalledTimes(1); }); test('injects legacy dependency to context#setup()', async () => { @@ -107,6 +110,7 @@ test('runs services on "start"', async () => { expect(mockLegacyService.start).not.toHaveBeenCalled(); expect(mockSavedObjectsService.start).not.toHaveBeenCalled(); expect(mockUiSettingsService.start).not.toHaveBeenCalled(); + expect(mockMetricsService.start).not.toHaveBeenCalled(); await server.start(); @@ -114,6 +118,7 @@ test('runs services on "start"', async () => { expect(mockLegacyService.start).toHaveBeenCalledTimes(1); expect(mockSavedObjectsService.start).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.start).toHaveBeenCalledTimes(1); + expect(mockMetricsService.start).toHaveBeenCalledTimes(1); }); test('does not fail on "setup" if there are unused paths detected', async () => { @@ -135,6 +140,7 @@ test('stops services on "stop"', async () => { expect(mockLegacyService.stop).not.toHaveBeenCalled(); expect(mockSavedObjectsService.stop).not.toHaveBeenCalled(); expect(mockUiSettingsService.stop).not.toHaveBeenCalled(); + expect(mockMetricsService.stop).not.toHaveBeenCalled(); await server.stop(); @@ -144,6 +150,7 @@ test('stops services on "stop"', async () => { expect(mockLegacyService.stop).toHaveBeenCalledTimes(1); expect(mockSavedObjectsService.stop).toHaveBeenCalledTimes(1); expect(mockUiSettingsService.stop).toHaveBeenCalledTimes(1); + expect(mockMetricsService.stop).toHaveBeenCalledTimes(1); }); test(`doesn't setup core services if config validation fails`, async () => { @@ -159,6 +166,7 @@ test(`doesn't setup core services if config validation fails`, async () => { expect(mockLegacyService.setup).not.toHaveBeenCalled(); expect(mockUiSettingsService.setup).not.toHaveBeenCalled(); expect(mockRenderingService.setup).not.toHaveBeenCalled(); + expect(mockMetricsService.setup).not.toHaveBeenCalled(); }); test(`doesn't setup core services if legacy config validation fails`, async () => { @@ -178,4 +186,5 @@ test(`doesn't setup core services if legacy config validation fails`, async () = expect(mockLegacyService.setup).not.toHaveBeenCalled(); expect(mockSavedObjectsService.stop).not.toHaveBeenCalled(); expect(mockUiSettingsService.setup).not.toHaveBeenCalled(); + expect(mockMetricsService.setup).not.toHaveBeenCalled(); }); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index db2493b38d6e0ab..8603f5fba1da87b 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -34,6 +34,7 @@ import { Logger, LoggerFactory } from './logging'; import { UiSettingsService } from './ui_settings'; import { PluginsService, config as pluginsConfig } from './plugins'; import { SavedObjectsService } from '../server/saved_objects'; +import { MetricsService, opsConfig } from './metrics'; import { config as cspConfig } from './csp'; import { config as elasticsearchConfig } from './elasticsearch'; @@ -67,6 +68,7 @@ export class Server { private readonly savedObjects: SavedObjectsService; private readonly uiSettings: UiSettingsService; private readonly uuid: UuidService; + private readonly metrics: MetricsService; private coreStart?: InternalCoreStart; @@ -89,6 +91,7 @@ export class Server { this.uiSettings = new UiSettingsService(core); this.capabilities = new CapabilitiesService(core); this.uuid = new UuidService(core); + this.metrics = new MetricsService(core); } public async setup() { @@ -137,6 +140,8 @@ export class Server { legacyPlugins, }); + const metricsSetup = await this.metrics.setup({ http: httpSetup }); + const coreSetup: InternalCoreSetup = { capabilities: capabilitiesSetup, context: contextServiceSetup, @@ -145,6 +150,7 @@ export class Server { uiSettings: uiSettingsSetup, savedObjects: savedObjectsSetup, uuid: uuidSetup, + metrics: metricsSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); @@ -193,6 +199,7 @@ export class Server { await this.http.start(); await this.rendering.start(); + await this.metrics.start(); return this.coreStart; } @@ -207,6 +214,7 @@ export class Server { await this.http.stop(); await this.uiSettings.stop(); await this.rendering.stop(); + await this.metrics.stop(); } private registerDefaultRoute(httpSetup: InternalHttpServiceSetup) { @@ -260,6 +268,7 @@ export class Server { [savedObjectsConfig.path, savedObjectsConfig.schema], [savedObjectsMigrationConfig.path, savedObjectsMigrationConfig.schema], [uiSettingsConfig.path, uiSettingsConfig.schema], + [opsConfig.path, opsConfig.schema], ]; this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); From f7dd5fe4d42e9673256d2c0423fdcfc4e28cdf91 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger <walter@elastic.co> Date: Tue, 3 Mar 2020 11:05:57 +0100 Subject: [PATCH 13/22] [ML] Transforms: Remove beta badges. (#59060) Transforms will be GA in 7.7. This PR removes the beta related UI elements. --- .../clone_transform/clone_transform_section.tsx | 13 ------------- .../create_transform/create_transform_section.tsx | 14 -------------- .../transform_management_section.tsx | 14 -------------- .../plugins/translations/translations/ja-JP.json | 4 ---- .../plugins/translations/translations/zh-CN.json | 4 ---- 5 files changed, 49 deletions(-) diff --git a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index 8f58bc94e7c1213..c5c46dcac6c9576 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -11,7 +11,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { - EuiBetaBadge, EuiButtonEmpty, EuiCallOut, EuiFlexGroup, @@ -140,18 +139,6 @@ export const CloneTransformSection: FC<Props> = ({ match }) => { id="xpack.transform.transformsWizard.cloneTransformTitle" defaultMessage="Clone transform" /> - <span> </span> - <EuiBetaBadge - label={i18n.translate('xpack.transform.transformsWizard.betaBadgeLabel', { - defaultMessage: `Beta`, - })} - tooltipContent={i18n.translate( - 'xpack.transform.transformsWizard.betaBadgeTooltipContent', - { - defaultMessage: `Transforms are a beta feature. We'd love to hear your feedback.`, - } - )} - /> </h1> </EuiFlexItem> <EuiFlexItem grow={false}> diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx index e92ba256256a4af..5196f281adf0aaa 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx @@ -8,10 +8,8 @@ import React, { useEffect, FC } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; import { - EuiBetaBadge, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, @@ -52,18 +50,6 @@ export const CreateTransformSection: FC<Props> = ({ match }) => { id="xpack.transform.transformsWizard.createTransformTitle" defaultMessage="Create transform" /> - <span> </span> - <EuiBetaBadge - label={i18n.translate('xpack.transform.transformsWizard.betaBadgeLabel', { - defaultMessage: `Beta`, - })} - tooltipContent={i18n.translate( - 'xpack.transform.transformsWizard.betaBadgeTooltipContent', - { - defaultMessage: `Transforms are a beta feature. We'd love to hear your feedback.`, - } - )} - /> </h1> </EuiFlexItem> <EuiFlexItem grow={false}> diff --git a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 1573d4c53c0cf46..8c174098fb623d9 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/legacy/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -7,10 +7,8 @@ import React, { FC, Fragment, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; import { - EuiBetaBadge, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, @@ -84,18 +82,6 @@ export const TransformManagement: FC = () => { id="xpack.transform.transformList.transformTitle" defaultMessage="Transforms" /> - <span> </span> - <EuiBetaBadge - label={i18n.translate('xpack.transform.transformList.betaBadgeLabel', { - defaultMessage: `Beta`, - })} - tooltipContent={i18n.translate( - 'xpack.transform.transformList.betaBadgeTooltipContent', - { - defaultMessage: `Transforms are a beta feature. We'd love to hear your feedback.`, - } - )} - /> </h1> </EuiFlexItem> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cadebcad935106e..7fdffbec7831188 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12503,8 +12503,6 @@ "xpack.transform.toastText.modalTitle": "詳細を入力", "xpack.transform.toastText.openModalButtonText": "詳細を表示", "xpack.transform.transformForm.sizeNotationPlaceholder": "例: {example1}、{example2}、{example3}、{example4}", - "xpack.transform.transformList.betaBadgeLabel": "ベータ", - "xpack.transform.transformList.betaBadgeTooltipContent": "変換はベータ機能です。フィードバックをお待ちしています。", "xpack.transform.transformList.bulkDeleteModalBody": "{count, plural, one {この} other {これらの}} {count} 件の{count, plural, one {変換} other {変換}}を削除してよろしいですか?変換の送信先インデックスとオプションの Kibana インデックスパターンは削除されません。", "xpack.transform.transformList.bulkDeleteModalTitle": "{count} 件の{count, plural, one {変換} other {変換}}を削除", "xpack.transform.transformList.bulkStartModalTitle": "{count} 件の{count, plural, one {変換} other {変換}}を開始", @@ -12549,8 +12547,6 @@ "xpack.transform.transformList.transformDetails.tabs.transformSettingsLabel": "ジョブの詳細", "xpack.transform.transformList.transformDocsLinkText": "変換ドキュメント", "xpack.transform.transformList.transformTitle": "データフレームジョブ", - "xpack.transform.transformsWizard.betaBadgeLabel": "ベータ", - "xpack.transform.transformsWizard.betaBadgeTooltipContent": "変換はベータ機能です。フィードバックをお待ちしています。", "xpack.transform.transformsWizard.createTransformTitle": "変換の作成", "xpack.transform.transformsWizard.stepCreateTitle": "作成", "xpack.transform.transformsWizard.stepDefineTitle": "ピボットの定義", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ff72f20b248644d..438a8f919750819 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12503,8 +12503,6 @@ "xpack.transform.toastText.modalTitle": "错误详细信息", "xpack.transform.toastText.openModalButtonText": "查看详情", "xpack.transform.transformForm.sizeNotationPlaceholder": "示例:{example1}、{example2}、{example3}、{example4}", - "xpack.transform.transformList.betaBadgeLabel": "公测版", - "xpack.transform.transformList.betaBadgeTooltipContent": "转换为公测版功能。我们很乐意听取您的反馈意见。", "xpack.transform.transformList.bulkDeleteModalBody": "是否确定要删除{count, plural, one {这} other {这}} {count} 个 {count, plural, one {转换} other {转换}}?转换的目标索引和可选 Kibana 索引模式将不会删除。", "xpack.transform.transformList.bulkDeleteModalTitle": "删除 {count} 个 {count, plural, one {转换} other {转换}}?", "xpack.transform.transformList.bulkStartModalTitle": "启动 {count} 个 {count, plural, one {转换} other {转换}}?", @@ -12549,8 +12547,6 @@ "xpack.transform.transformList.transformDetails.tabs.transformSettingsLabel": "作业详情", "xpack.transform.transformList.transformDocsLinkText": "转换文档", "xpack.transform.transformList.transformTitle": "数据帧作业", - "xpack.transform.transformsWizard.betaBadgeLabel": "公测版", - "xpack.transform.transformsWizard.betaBadgeTooltipContent": "转换为公测版功能。我们很乐意听取您的反馈意见。", "xpack.transform.transformsWizard.createTransformTitle": "创建转换", "xpack.transform.transformsWizard.stepCreateTitle": "创建", "xpack.transform.transformsWizard.stepDefineTitle": "定义透视", From e5362d36a3bae9d55b9b89a462cd4a0ba3a43210 Mon Sep 17 00:00:00 2001 From: Maryia Lapata <mary.lopato@gmail.com> Date: Tue, 3 Mar 2020 13:58:05 +0300 Subject: [PATCH 14/22] [NP] Remove visualize reference in saved object save modal (#59016) * Remove visualize reference in saved object save modal * Rename attribute Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../top_nav/__snapshots__/save_modal.test.js.snap | 1 + .../public/dashboard/np_ready/top_nav/save_modal.tsx | 1 + .../public/discover/np_ready/angular/discover.js | 1 + .../kibana/public/visualize/np_ready/editor/editor.js | 1 + .../public/save_modal/saved_object_save_modal.test.tsx | 2 ++ .../public/save_modal/saved_object_save_modal.tsx | 10 +++------- .../plugins/graph/public/components/save_modal.tsx | 1 + x-pack/legacy/plugins/lens/public/app_plugin/app.tsx | 1 + .../plugins/maps/public/angular/map_controller.js | 1 + 9 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/save_modal.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/save_modal.test.js.snap index aa9eaf09c7e0a8d..7ac2e2d9dd31767 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/save_modal.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/__snapshots__/save_modal.test.js.snap @@ -59,6 +59,7 @@ exports[`renders DashboardSaveModal 1`] = ` </React.Fragment> } showCopyOnSave={true} + showDescription={false} title="dash title" /> `; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/save_modal.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/save_modal.tsx index 026784fcae06f4c..4a4fcb7e1adc86c 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/save_modal.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/save_modal.tsx @@ -147,6 +147,7 @@ export class DashboardSaveModal extends React.Component<Props, State> { showCopyOnSave={this.props.showCopyOnSave} objectType="dashboard" options={this.renderDashboardSaveOptions()} + showDescription={false} /> ); } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js index 1ac54ad5dabeeff..bb693ab860221c0 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/discover.js @@ -305,6 +305,7 @@ function discoverController( defaultMessage: 'Save your Discover search so you can use it in visualizations and dashboards', })} + showDescription={false} /> ); showSaveModal(saveModal, core.i18n.Context); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index 293327f3f72f9ff..2d2552b5e2f30c6 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -189,6 +189,7 @@ function VisualizeAppController( objectType="visualization" confirmButtonLabel={confirmButtonLabel} description={savedVis.description} + showDescription={true} /> ); showSaveModal(saveModal, I18nContext); diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx index 65bd8e1d48e036e..15400087c264163 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.test.tsx @@ -31,6 +31,7 @@ describe('SavedObjectSaveModal', () => { title={'Saved Object title'} showCopyOnSave={false} objectType="visualization" + showDescription={true} /> ); expect(wrapper).toMatchSnapshot(); @@ -44,6 +45,7 @@ describe('SavedObjectSaveModal', () => { title={'Saved Object title'} showCopyOnSave={false} objectType="visualization" + showDescription={true} confirmButtonLabel="Save and done" /> ); diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 275a9da96a2c4d7..1d145bc97bdb4bf 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -40,11 +40,6 @@ import React from 'react'; import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -// TODO: can't import from '../../../../legacy/core_plugins/visualizations/public/' directly, -// because yarn build:types fails after trying to emit type declarations for whole visualizations plugin -// Bunch of errors like this: 'Return type of exported function has or is using private name 'SavedVis'' -import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../legacy/core_plugins/visualizations/public/np_ready/public/embeddable/constants'; - export interface OnSaveProps { newTitle: string; newCopyOnSave: boolean; @@ -62,6 +57,7 @@ interface Props { confirmButtonLabel?: React.ReactNode; options?: React.ReactNode; description?: string; + showDescription: boolean; } interface State { @@ -112,7 +108,7 @@ export class SavedObjectSaveModal extends React.Component<Props, State> { {this.renderDuplicateTitleCallout(duplicateWarningId)} <EuiForm> - {this.props.objectType !== VISUALIZE_EMBEDDABLE_TYPE && this.props.description && ( + {!this.props.showDescription && this.props.description && ( <EuiFormRow> <EuiText color="subdued">{this.props.description}</EuiText> </EuiFormRow> @@ -164,7 +160,7 @@ export class SavedObjectSaveModal extends React.Component<Props, State> { } private renderViewDescription = () => { - if (this.props.objectType !== VISUALIZE_EMBEDDABLE_TYPE) { + if (!this.props.showDescription) { return; } diff --git a/x-pack/legacy/plugins/graph/public/components/save_modal.tsx b/x-pack/legacy/plugins/graph/public/components/save_modal.tsx index 3dede69d0ca93ff..a7329c10e93d7c2 100644 --- a/x-pack/legacy/plugins/graph/public/components/save_modal.tsx +++ b/x-pack/legacy/plugins/graph/public/components/save_modal.tsx @@ -49,6 +49,7 @@ export function SaveModal({ objectType={i18n.translate('xpack.graph.topNavMenu.save.objectType', { defaultMessage: 'graph', })} + showDescription={false} options={ <> <EuiFormRow diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index a212cb0a1a8797f..a0c6e4c21a34bd8 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -374,6 +374,7 @@ export function App({ objectType={i18n.translate('xpack.lens.app.saveModalType', { defaultMessage: 'Lens visualization', })} + showDescription={false} confirmButtonLabel={confirmButton} /> )} diff --git a/x-pack/legacy/plugins/maps/public/angular/map_controller.js b/x-pack/legacy/plugins/maps/public/angular/map_controller.js index a8e9ae46a3b9ad5..84ead42d3374e18 100644 --- a/x-pack/legacy/plugins/maps/public/angular/map_controller.js +++ b/x-pack/legacy/plugins/maps/public/angular/map_controller.js @@ -595,6 +595,7 @@ app.controller( title={savedMap.title} showCopyOnSave={savedMap.id ? true : false} objectType={MAP_SAVED_OBJECT_TYPE} + showDescription={false} /> ); showSaveModal(saveModal, npStart.core.i18n.Context); From 4ef594c20896c84380bb2e6ad1a7730ca2c43608 Mon Sep 17 00:00:00 2001 From: James Gowdy <jgowdy@elastic.co> Date: Tue, 3 Mar 2020 11:59:35 +0000 Subject: [PATCH 15/22] [ML] Adding indices_options to datafeed (#59119) * [ML] Adding indices_options to datafeed * adding extra checks to the schema * updating expand_wildcards --- .../common/job_creator/configs/datafeed.ts | 8 ++++++++ .../ml/server/routes/schemas/datafeeds_schema.ts | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/configs/datafeed.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/configs/datafeed.ts index e35f3056ce4347d..538b225926f6527 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/configs/datafeed.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/configs/datafeed.ts @@ -25,6 +25,7 @@ export interface Datafeed { script_fields?: object; scroll_size?: number; delayed_data_check_config?: object; + indices_options?: IndicesOptions; } export interface ChunkingConfig { @@ -42,3 +43,10 @@ interface Aggregation { aggs?: { [key: string]: any }; }; } + +interface IndicesOptions { + expand_wildcards?: 'all' | 'open' | 'closed' | 'hidden' | 'none'; + ignore_unavailable?: boolean; + allow_no_indices?: boolean; + ignore_throttled?: boolean; +} diff --git a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts index 02677dcb107c2f1..ee49da6538460c6 100644 --- a/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/datafeeds_schema.ts @@ -17,7 +17,12 @@ export const datafeedConfigSchema = schema.object({ feed_id: schema.maybe(schema.string()), aggregations: schema.maybe(schema.any()), aggs: schema.maybe(schema.any()), - chunking_config: schema.maybe(schema.any()), + chunking_config: schema.maybe( + schema.object({ + mode: schema.maybe(schema.string()), + time_span: schema.maybe(schema.string()), + }) + ), frequency: schema.maybe(schema.string()), indices: schema.arrayOf(schema.string()), indexes: schema.maybe(schema.arrayOf(schema.string())), @@ -28,4 +33,12 @@ export const datafeedConfigSchema = schema.object({ script_fields: schema.maybe(schema.any()), scroll_size: schema.maybe(schema.number()), delayed_data_check_config: schema.maybe(schema.any()), + indices_options: schema.maybe( + schema.object({ + expand_wildcards: schema.maybe(schema.arrayOf(schema.string())), + ignore_unavailable: schema.maybe(schema.boolean()), + allow_no_indices: schema.maybe(schema.boolean()), + ignore_throttled: schema.maybe(schema.boolean()), + }) + ), }); From bfca202c4f08af4f430e688afa6efde79aa51368 Mon Sep 17 00:00:00 2001 From: Justin Kambic <justin.kambic@elastic.co> Date: Tue, 3 Mar 2020 07:52:57 -0500 Subject: [PATCH 16/22] [Uptime] Add default chart empty state (#57725) * Add default chart empty state. * Delete obsolete translations. Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../chart_empty_state.test.tsx.snap | 49 +++++++ .../__tests__/chart_empty_state.test.tsx | 35 +++++ .../functional/charts/chart_empty_state.tsx | 24 ++++ .../functional/charts/checks_chart.tsx | 124 ------------------ .../functional/charts/duration_chart.tsx | 15 ++- .../charts/duration_chart_empty_state.tsx | 33 ----- .../functional/charts/ping_histogram.tsx | 104 ++++++--------- .../translations/translations/ja-JP.json | 5 - .../translations/translations/zh-CN.json | 5 - 9 files changed, 163 insertions(+), 231 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap new file mode 100644 index 000000000000000..79ef7b3b97abdf4 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/__snapshots__/chart_empty_state.test.tsx.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChartEmptyState renders JSX values 1`] = ` +<EuiEmptyPrompt + body={ + <p> + <FormattedMessage + defaultMessage="This is the default with a {val} included" + id="test.body" + values={ + Object { + "val": <strong> + down + </strong>, + } + } + /> + </p> + } + title={ + <EuiTitle> + <h5> + <FormattedMessage + defaultMessage="The title" + id="test.title" + values={Object {}} + /> + </h5> + </EuiTitle> + } +/> +`; + +exports[`ChartEmptyState renders string values 1`] = ` +<EuiEmptyPrompt + body={ + <p> + This is the body + </p> + } + title={ + <EuiTitle> + <h5> + This is the title + </h5> + </EuiTitle> + } +/> +`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx new file mode 100644 index 000000000000000..2e25dddc0b4edfc --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/__tests__/chart_empty_state.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ChartEmptyState } from '../chart_empty_state'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +describe('ChartEmptyState', () => { + it('renders string values', () => { + expect( + shallowWithIntl(<ChartEmptyState body="This is the body" title="This is the title" />) + ).toMatchSnapshot(); + }); + + it('renders JSX values', () => { + expect( + shallowWithIntl( + <ChartEmptyState + body={ + <FormattedMessage + id="test.body" + defaultMessage="This is the default with a {val} included" + values={{ val: <strong>down</strong> }} + /> + } + title={<FormattedMessage id="test.title" defaultMessage="The title" />} + /> + ) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx new file mode 100644 index 000000000000000..19202822fe737cc --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/chart_empty_state.tsx @@ -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 { EuiEmptyPrompt, EuiTitle } from '@elastic/eui'; +import React, { FC } from 'react'; + +interface ChartEmptyStateProps { + title: string | JSX.Element; + body: string | JSX.Element; +} + +export const ChartEmptyState: FC<ChartEmptyStateProps> = ({ title, body }) => ( + <EuiEmptyPrompt + title={ + <EuiTitle> + <h5>{title}</h5> + </EuiTitle> + } + body={<p>{body}</p>} + /> +); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx deleted file mode 100644 index a88a9668660f7a5..000000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/checks_chart.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - AreaSeries, - Axis, - Chart, - Position, - Settings, - ScaleType, - timeFormatter, -} from '@elastic/charts'; -import { EuiPanel, EuiTitle } from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { StatusData } from '../../../../common/graphql/types'; -import { getChartDateLabel } from '../../../lib/helper'; -import { useUrlParams } from '../../../hooks'; - -interface ChecksChartProps { - /** - * The color that will be used for the area series displaying "Down" checks. - */ - dangerColor: string; - /** - * The timeseries data displayed in the chart. - */ - status: StatusData[]; - /** - * The color that will be used for the area series displaying "Up" checks. - */ - successColor: string; -} - -/** - * Renders a chart that displays the total count of up/down status checks over time - * as a stacked area chart. - * @param props The props values required by this component. - */ -export const ChecksChart = ({ dangerColor, status, successColor }: ChecksChartProps) => { - const upSeriesSpecId = 'Up'; - const downSeriesSpecId = 'Down'; - const [getUrlParams] = useUrlParams(); - const { absoluteDateRangeStart: min, absoluteDateRangeEnd: max } = getUrlParams(); - - const upString = i18n.translate('xpack.uptime.monitorCharts.checkStatus.series.upCountLabel', { - defaultMessage: 'Up count', - }); - const downString = i18n.translate( - 'xpack.uptime.monitorCharts.checkStatus.series.downCountLabel', - { - defaultMessage: 'Down count', - } - ); - - return ( - <React.Fragment> - <EuiTitle size="xs"> - <h4> - <FormattedMessage - id="xpack.uptime.monitorCharts.checkStatus.title" - defaultMessage="Check status" - /> - </h4> - </EuiTitle> - <EuiPanel> - <Chart> - <Settings xDomain={{ min, max }} showLegend={false} /> - <Axis - id="checksBottom" - position={Position.Bottom} - showOverlappingTicks={true} - tickFormat={timeFormatter(getChartDateLabel(min, max))} - title={i18n.translate('xpack.uptime.monitorChart.checksChart.bottomAxis.title', { - defaultMessage: 'Timestamp', - description: 'The heading of the x-axis of a chart of timeseries data.', - })} - /> - <Axis - id="left" - position={Position.Left} - tickFormat={d => Number(d).toFixed(0)} - title={i18n.translate('xpack.uptime.monitorChart.checksChart.leftAxis.title', { - defaultMessage: 'Number of checks', - description: 'The heading of the y-axis of a chart of timeseries data', - })} - /> - <AreaSeries - customSeriesColors={[successColor]} - data={status.map(({ x, up }) => ({ - x, - [upString]: up || 0, - }))} - id={upSeriesSpecId} - stackAccessors={['x']} - timeZone="local" - xAccessor="x" - xScaleType={ScaleType.Time} - yAccessors={[upString]} - yScaleType={ScaleType.Linear} - /> - <AreaSeries - customSeriesColors={[downSeriesSpecId]} - data={status.map(({ x, down }) => ({ - x, - [downString]: down || 0, - }))} - id={downSeriesSpecId} - stackAccessors={['x']} - timeZone="local" - xAccessor="x" - xScaleType={ScaleType.Time} - yAccessors={[downString]} - yScaleType={ScaleType.Linear} - /> - </Chart> - </EuiPanel> - </React.Fragment> - ); -}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx index 7a6db6d952dd916..0488e2531bc98d8 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart.tsx @@ -13,10 +13,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { getChartDateLabel } from '../../../lib/helper'; import { LocationDurationLine } from '../../../../common/graphql/types'; import { DurationLineSeriesList } from './duration_line_series_list'; -import { DurationChartEmptyState } from './duration_chart_empty_state'; import { ChartWrapper } from './chart_wrapper'; import { useUrlParams } from '../../../hooks'; import { getTickFormat } from './get_tick_format'; +import { ChartEmptyState } from './chart_empty_state'; interface DurationChartProps { /** @@ -102,7 +102,18 @@ export const DurationChart = ({ <DurationLineSeriesList lines={locationDurationLines} meanColor={meanColor} /> </Chart> ) : ( - <DurationChartEmptyState /> + <ChartEmptyState + body={ + <FormattedMessage + id="xpack.uptime.durationChart.emptyPrompt.description" + defaultMessage="This monitor has never been {emphasizedText} during the selected time range." + values={{ emphasizedText: <strong>up</strong> }} + /> + } + title={i18n.translate('xpack.uptime.durationChart.emptyPrompt.title', { + defaultMessage: 'No duration data available', + })} + /> )} </ChartWrapper> </EuiPanel> diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx deleted file mode 100644 index ef4e70bf6589840..000000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/duration_chart_empty_state.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiEmptyPrompt, EuiTitle } from '@elastic/eui'; -import React from 'react'; - -export const DurationChartEmptyState = () => ( - <EuiEmptyPrompt - title={ - <EuiTitle> - <h5> - <FormattedMessage - id="xpack.uptime.durationChart.emptyPrompt.title" - defaultMessage="No duration data available" - /> - </h5> - </EuiTitle> - } - body={ - <p> - <FormattedMessage - id="xpack.uptime.durationChart.emptyPrompt.description" - defaultMessage="This monitor has never been {emphasizedText} during the selected time range." - values={{ emphasizedText: <strong>up</strong> }} - /> - </p> - } - /> -); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx index b4989282f854c54..6119d897cbf53bf 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/charts/ping_histogram.tsx @@ -5,7 +5,7 @@ */ import { Axis, BarSeries, Chart, Position, Settings, timeFormatter } from '@elastic/charts'; -import { EuiEmptyPrompt, EuiPanel, EuiTitle } from '@elastic/eui'; +import { EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useContext } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -15,6 +15,7 @@ import { ChartWrapper } from './chart_wrapper'; import { UptimeThemeContext } from '../../../contexts'; import { HistogramResult } from '../../../../common/types'; import { useUrlParams } from '../../../hooks'; +import { ChartEmptyState } from './chart_empty_state'; export interface PingHistogramComponentProps { /** @@ -49,71 +50,36 @@ export const PingHistogramComponent: React.FC<PingHistogramComponentProps> = ({ const [, updateUrlParams] = useUrlParams(); - if (!data || !data.histogram) - /** - * TODO: the Fragment, EuiTitle, and EuiPanel should be extracted to a dumb component - * that we can reuse in the subsequent return statement at the bottom of this function. - */ - return ( - <> - <EuiTitle size="xs"> - <h5> - <FormattedMessage - id="xpack.uptime.snapshot.pingsOverTimeTitle" - defaultMessage="Pings over time" - /> - </h5> - </EuiTitle> - <EuiPanel paddingSize="s" style={{ height: 170 }}> - <EuiEmptyPrompt - title={ - <EuiTitle> - <h5> - <FormattedMessage - id="xpack.uptime.snapshot.noDataTitle" - defaultMessage="No histogram data available" - /> - </h5> - </EuiTitle> - } - body={ - <p> - <FormattedMessage - id="xpack.uptime.snapshot.noDataDescription" - defaultMessage="Sorry, there is no data available for the histogram" - /> - </p> - } - /> - </EuiPanel> - </> + let content: JSX.Element | undefined; + if (!data?.histogram?.length) { + content = ( + <ChartEmptyState + title={i18n.translate('xpack.uptime.snapshot.noDataTitle', { + defaultMessage: 'No ping data available', + })} + body={i18n.translate('xpack.uptime.snapshot.noDataDescription', { + defaultMessage: 'There are no pings in the selected time range.', + })} + /> ); - const { histogram } = data; - - const downSpecId = i18n.translate('xpack.uptime.snapshotHistogram.downMonitorsId', { - defaultMessage: 'Down Monitors', - }); + } else { + const { histogram } = data; - const upMonitorsId = i18n.translate('xpack.uptime.snapshotHistogram.series.upLabel', { - defaultMessage: 'Up', - }); + const downSpecId = i18n.translate('xpack.uptime.snapshotHistogram.downMonitorsId', { + defaultMessage: 'Down Monitors', + }); - const onBrushEnd = (min: number, max: number) => { - updateUrlParams({ - dateRangeStart: moment(min).toISOString(), - dateRangeEnd: moment(max).toISOString(), + const upMonitorsId = i18n.translate('xpack.uptime.snapshotHistogram.series.upLabel', { + defaultMessage: 'Up', }); - }; - return ( - <> - <EuiTitle size="xs"> - <h2> - <FormattedMessage - id="xpack.uptime.snapshot.pingsOverTimeTitle" - defaultMessage="Pings over time" - /> - </h2> - </EuiTitle> + + const onBrushEnd = (min: number, max: number) => { + updateUrlParams({ + dateRangeStart: moment(min).toISOString(), + dateRangeEnd: moment(max).toISOString(), + }); + }; + content = ( <ChartWrapper height={height} loading={loading} @@ -183,6 +149,20 @@ export const PingHistogramComponent: React.FC<PingHistogramComponentProps> = ({ /> </Chart> </ChartWrapper> + ); + } + + return ( + <> + <EuiTitle size="xs"> + <h2> + <FormattedMessage + id="xpack.uptime.snapshot.pingsOverTimeTitle" + defaultMessage="Pings over time" + /> + </h2> + </EuiTitle> + {content} </> ); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7fdffbec7831188..75db834a969d0e3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12689,11 +12689,6 @@ "xpack.uptime.kueryBar.indexPatternMissingWarningMessage": "インデックスパターンの取得中にエラーが発生しました。", "xpack.uptime.kueryBar.searchPlaceholder": "モニター ID、名前、プロトコルタイプなどを検索…", "xpack.uptime.locationName.helpLinkAnnotation": "場所を追加", - "xpack.uptime.monitorChart.checksChart.bottomAxis.title": "タイムスタンプ", - "xpack.uptime.monitorChart.checksChart.leftAxis.title": "チェックの数", - "xpack.uptime.monitorCharts.checkStatus.series.downCountLabel": "ダウンカウント", - "xpack.uptime.monitorCharts.checkStatus.series.upCountLabel": "アップカウント", - "xpack.uptime.monitorCharts.checkStatus.title": "ステータスを確認", "xpack.uptime.monitorCharts.durationChart.bottomAxis.title": "タイムスタンプ", "xpack.uptime.monitorCharts.durationChart.leftAxis.title": "期間ms", "xpack.uptime.monitorCharts.loadingMessage": "読み込み中…", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 438a8f919750819..d67d2054a2da659 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12689,11 +12689,6 @@ "xpack.uptime.kueryBar.indexPatternMissingWarningMessage": "检索索引模式时出错。", "xpack.uptime.kueryBar.searchPlaceholder": "搜索监测 ID、名称和协议类型......", "xpack.uptime.locationName.helpLinkAnnotation": "添加位置", - "xpack.uptime.monitorChart.checksChart.bottomAxis.title": "鏃堕棿鎴", - "xpack.uptime.monitorChart.checksChart.leftAxis.title": "检查数目", - "xpack.uptime.monitorCharts.checkStatus.series.downCountLabel": "关闭计数", - "xpack.uptime.monitorCharts.checkStatus.series.upCountLabel": "运行计数", - "xpack.uptime.monitorCharts.checkStatus.title": "检查状态", "xpack.uptime.monitorCharts.durationChart.bottomAxis.title": "鏃堕棿鎴", "xpack.uptime.monitorCharts.durationChart.leftAxis.title": "持续时间 (ms)", "xpack.uptime.monitorCharts.loadingMessage": "正在加载……", From 388705509ff6694268da8d5a3f3d300d2b843669 Mon Sep 17 00:00:00 2001 From: Patrick Mueller <pmuellr@gmail.com> Date: Tue, 3 Mar 2020 08:28:31 -0500 Subject: [PATCH 17/22] [Alerting]: get type-checking, tests, and ui working for index threshold (#59064) This is a follow-on to https://github.com/elastic/kibana/pull/57030 , "[alerting] initial index threshold alertType and supporting APIs", to get it working with the existing alerting UI. The parameter shape was different between the two, so the alertType was changed to fix the existing UI shapes expected. --- .../alert_types/index_threshold/README.md | 85 ++++++++------- .../index_threshold/action_context.test.ts | 30 +++-- .../index_threshold/action_context.ts | 5 +- .../index_threshold/alert_type.test.ts | 17 ++- .../alert_types/index_threshold/alert_type.ts | 25 +++-- .../index_threshold/alert_type_params.test.ts | 29 ++--- .../index_threshold/alert_type_params.ts | 30 ++--- .../lib/core_query_types.test.ts | 103 ++++++++++++------ .../index_threshold/lib/core_query_types.ts | 100 ++++++++++------- .../lib/time_series_query.test.ts | 24 ++-- .../index_threshold/lib/time_series_query.ts | 24 ++-- .../lib/time_series_types.test.ts | 15 ++- .../index_threshold/lib/time_series_types.ts | 19 +++- .../threshold/expression.tsx | 3 - .../builtin_alert_types/threshold/index.ts | 2 +- .../common/expression_items/group_by_over.tsx | 2 + .../index_threshold/query_data_endpoint.ts | 20 ++-- .../apps/triggers_actions_ui/alerts.ts | 45 +++++--- 18 files changed, 342 insertions(+), 236 deletions(-) diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md index b1a9e6daaaee30b..582c9df731a153c 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md @@ -58,43 +58,47 @@ Finally, create the alert: ``` kbn-alert create .index-threshold 'es-hb-sim threshold' 1s \ '{ - index: es-hb-sim - timeField: @timestamp - aggType: average - aggField: summary.up - groupField: monitor.name.keyword - window: 5s - comparator: lessThan - threshold: [ 0.6 ] + index: es-hb-sim + timeField: @timestamp + aggType: avg + aggField: summary.up + groupBy: top + termSize: 100 + termField: monitor.name.keyword + timeWindowSize: 5 + timeWindowUnit: s + thresholdComparator: < + threshold: [ 0.6 ] }' \ "[ { - group: threshold met - id: '$ACTION_ID' + group: threshold met + id: '$ACTION_ID' params: { - level: warn - message: '{{context.message}}' + level: warn + message: '{{{context.message}}}' } } ]" ``` This alert will run a query over the `es-hb-sim` index, using the `@timestamp` -field as the date field, using an `average` aggregation over the `summary.up` -field. The results are then aggregated by `monitor.name.keyword`. If we ran +field as the date field, aggregating over groups of the field value +`monitor.name.keyword` (the top 100 groups), then aggregating those values +using an `average` aggregation over the `summary.up` field. If we ran another instance of `es-hb-sim`, using `host-B` instead of `host-A`, then the alert will end up potentially scheduling actions for both, independently. Within the alerting plugin, this grouping is also referred to as "instanceIds" (`host-A` and `host-B` being distinct instanceIds, which can have actions scheduled against them independently). -The `window` is set to `5s` which is 5 seconds. That means, every time the +The time window is set to 5 seconds. That means, every time the alert runs it's queries (every second, in the example above), it will run it's ES query over the last 5 seconds. Thus, the queries, over time, will overlap. Sometimes that's what you want. Other times, maybe you just want to do sampling, running an alert every hour, with a 5 minute window. Up to the you! -Using the `comparator` `lessThan` and `threshold` `[0.6]`, the alert will +Using the `thresholdComparator` `<` and `threshold` `[0.6]`, the alert will calculate the average of all the `summary.up` fields for each unique `monitor.name.keyword`, and then if the value is less than 0.6, it will schedule the specified action (server log) to run. The `message` param @@ -110,11 +114,10 @@ working: ``` server log [17:32:10.060] [warning][actions][actions][plugins] \ - Server log: alert es-hb-sim threshold instance host-A value 0 \ - exceeded threshold average(summary.up) lessThan 0.6 over 5s \ + Server log: alert es-hb-sim threshold group host-A value 0 \ + exceeded threshold avg(summary.up) < 0.6 over 5s \ on 2020-02-20T22:32:07.000Z ``` - [kbn-action]: https://github.com/pmuellr/kbn-action [es-hb-sim]: https://github.com/pmuellr/es-hb-sim [now-iso]: https://github.com/pmuellr/now-iso @@ -144,15 +147,18 @@ This example uses [now-iso][] to generate iso date strings. ```console curl -k "https://elastic:changeme@localhost:5601/api/alerting_builtins/index_threshold/_time_series_query" \ -H "kbn-xsrf: foo" -H "content-type: application/json" -d "{ - \"index\": \"es-hb-sim\", - \"timeField\": \"@timestamp\", - \"aggType\": \"average\", - \"aggField\": \"summary.up\", - \"groupField\": \"monitor.name.keyword\", - \"interval\": \"1s\", - \"dateStart\": \"`now-iso -10s`\", - \"dateEnd\": \"`now-iso`\", - \"window\": \"5s\" + \"index\": \"es-hb-sim\", + \"timeField\": \"@timestamp\", + \"aggType\": \"avg\", + \"aggField\": \"summary.up\", + \"groupBy\": \"top\", + \"termSize\": 100, + \"termField\": \"monitor.name.keyword\", + \"interval\": \"1s\", + \"dateStart\": \"`now-iso -10s`\", + \"dateEnd\": \"`now-iso`\", + \"timeWindowSize\": 5, + \"timeWindowUnit\": \"s\" }" ``` @@ -184,13 +190,16 @@ To get the current value of the calculated metric, you can leave off the date: ``` curl -k "https://elastic:changeme@localhost:5601/api/alerting_builtins/index_threshold/_time_series_query" \ -H "kbn-xsrf: foo" -H "content-type: application/json" -d '{ - "index": "es-hb-sim", - "timeField": "@timestamp", - "aggType": "average", - "aggField": "summary.up", - "groupField": "monitor.name.keyword", - "interval": "1s", - "window": "5s" + "index": "es-hb-sim", + "timeField": "@timestamp", + "aggType": "avg", + "aggField": "summary.up", + "groupBy": "top", + "termField": "monitor.name.keyword", + "termSize": 100, + "interval": "1s", + "timeWindowSize": 5, + "timeWindowUnit": "s" }' ``` @@ -254,7 +263,7 @@ be ~24 time series points in the output. For preview purposes: -- The `groupLimit` parameter should be used to help cut +- The `termSize` parameter should be used to help cut down on the amount of work ES does, and keep the generated graphs a little simpler. Probably something like `10`. @@ -263,9 +272,9 @@ simpler. Probably something like `10`. could result in a lot of time-series points being generated, which is both costly in ES, and may result in noisy graphs. -- The `window` parameter should be the same as what the alert is using, +- The `timeWindow*` parameters should be the same as what the alert is using, especially for the `count` and `sum` aggregation types. Those aggregations don't scale the same way the others do, when the window changes. Even for the other aggregations, changing the window could result in dramatically -different values being generated - `averages` will be more "average-y", `min` +different values being generated - `avg` will be more "average-y", `min` and `max` will be a little stickier. \ No newline at end of file diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts index fbadf14f1d560de..e4cba7855e5f6e9 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts @@ -21,8 +21,12 @@ describe('ActionContext', () => { index: '[index]', timeField: '[timeField]', aggType: 'count', - window: '5m', - comparator: 'greaterThan', + groupBy: 'top', + termField: 'x', + termSize: 100, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', threshold: [4], }); const context = addMessages(base, params); @@ -30,7 +34,7 @@ describe('ActionContext', () => { `"alert [name] group [group] exceeded threshold"` ); expect(context.message).toMatchInlineSnapshot( - `"alert [name] group [group] value 42 exceeded threshold count greaterThan 4 over 5m on 2020-01-01T00:00:00.000Z"` + `"alert [name] group [group] value 42 exceeded threshold count > 4 over 5m on 2020-01-01T00:00:00.000Z"` ); }); @@ -46,10 +50,14 @@ describe('ActionContext', () => { const params = ParamsSchema.validate({ index: '[index]', timeField: '[timeField]', - aggType: 'average', + aggType: 'avg', + groupBy: 'top', + termField: 'x', + termSize: 100, aggField: '[aggField]', - window: '5m', - comparator: 'greaterThan', + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', threshold: [4.2], }); const context = addMessages(base, params); @@ -57,7 +65,7 @@ describe('ActionContext', () => { `"alert [name] group [group] exceeded threshold"` ); expect(context.message).toMatchInlineSnapshot( - `"alert [name] group [group] value 42 exceeded threshold average([aggField]) greaterThan 4.2 over 5m on 2020-01-01T00:00:00.000Z"` + `"alert [name] group [group] value 42 exceeded threshold avg([aggField]) > 4.2 over 5m on 2020-01-01T00:00:00.000Z"` ); }); @@ -74,8 +82,12 @@ describe('ActionContext', () => { index: '[index]', timeField: '[timeField]', aggType: 'count', - window: '5m', - comparator: 'between', + groupBy: 'top', + termField: 'x', + termSize: 100, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: 'between', threshold: [4, 5], }); const context = addMessages(base, params); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts index 98a8e5ae14b7f0b..72e42c7c0c2fa29 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts @@ -47,8 +47,9 @@ export function addMessages(c: BaseActionContext, p: Params): ActionContext { ); const agg = p.aggField ? `${p.aggType}(${p.aggField})` : `${p.aggType}`; - const humanFn = `${agg} ${p.comparator} ${p.threshold.join(',')}`; + const humanFn = `${agg} ${p.thresholdComparator} ${p.threshold.join(',')}`; + const window = `${p.timeWindowSize}${p.timeWindowUnit}`; const message = i18n.translate( 'xpack.alertingBuiltins.indexThreshold.alertTypeContextMessageDescription', { @@ -59,7 +60,7 @@ export function addMessages(c: BaseActionContext, p: Params): ActionContext { group: c.group, value: c.value, function: humanFn, - window: p.window, + window, date: c.date, }, } diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts index f6e26cdaa283a9d..5034b1ee0cd0180 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts @@ -6,6 +6,7 @@ import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; import { getAlertType } from './alert_type'; +import { Params } from './alert_type_params'; describe('alertType', () => { const service = { @@ -24,12 +25,14 @@ describe('alertType', () => { }); it('validator succeeds with valid params', async () => { - const params = { + const params: Partial<Writable<Params>> = { index: 'index-name', timeField: 'time-field', aggType: 'count', - window: '5m', - comparator: 'greaterThan', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '<', threshold: [0], }; @@ -40,12 +43,14 @@ describe('alertType', () => { const paramsSchema = alertType.validate?.params; if (!paramsSchema) throw new Error('params validator not set'); - const params = { + const params: Partial<Writable<Params>> = { index: 'index-name', timeField: 'time-field', aggType: 'foo', - window: '5m', - comparator: 'greaterThan', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', threshold: [0], }; diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts index 2b0c07ed4355aad..4610e0fbaf0da60 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import { AlertType, AlertExecutorOptions } from '../../types'; import { Params, ParamsSchema } from './alert_type_params'; import { BaseActionContext, addMessages } from './action_context'; +import { TimeSeriesQuery } from './lib/time_series_query'; export const ID = '.index-threshold'; @@ -46,24 +47,26 @@ export function getAlertType(service: Service): AlertType { const { alertId, name, services } = options; const params: Params = options.params as Params; - const compareFn = ComparatorFns.get(params.comparator); + const compareFn = ComparatorFns.get(params.thresholdComparator); if (compareFn == null) { - throw new Error(getInvalidComparatorMessage(params.comparator)); + throw new Error(getInvalidComparatorMessage(params.thresholdComparator)); } const callCluster = services.callCluster; const date = new Date().toISOString(); // the undefined values below are for config-schema optional types - const queryParams = { + const queryParams: TimeSeriesQuery = { index: params.index, timeField: params.timeField, aggType: params.aggType, aggField: params.aggField, - groupField: params.groupField, - groupLimit: params.groupLimit, + groupBy: params.groupBy, + termField: params.termField, + termSize: params.termSize, dateStart: date, dateEnd: date, - window: params.window, + timeWindowSize: params.timeWindowSize, + timeWindowUnit: params.timeWindowUnit, interval: undefined, }; const result = await service.indexThreshold.timeSeriesQuery({ @@ -100,7 +103,7 @@ export function getAlertType(service: Service): AlertType { export function getInvalidComparatorMessage(comparator: string) { return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidComparatorErrorMessage', { - defaultMessage: 'invalid comparator specified: {comparator}', + defaultMessage: 'invalid thresholdComparator specified: {comparator}', values: { comparator, }, @@ -111,10 +114,10 @@ type ComparatorFn = (value: number, threshold: number[]) => boolean; function getComparatorFns(): Map<string, ComparatorFn> { const fns: Record<string, ComparatorFn> = { - lessThan: (value: number, threshold: number[]) => value < threshold[0], - lessThanOrEqual: (value: number, threshold: number[]) => value <= threshold[0], - greaterThanOrEqual: (value: number, threshold: number[]) => value >= threshold[0], - greaterThan: (value: number, threshold: number[]) => value > threshold[0], + '<': (value: number, threshold: number[]) => value < threshold[0], + '<=': (value: number, threshold: number[]) => value <= threshold[0], + '>=': (value: number, threshold: number[]) => value >= threshold[0], + '>': (value: number, threshold: number[]) => value > threshold[0], between: (value: number, threshold: number[]) => value >= threshold[0] && value <= threshold[1], notBetween: (value: number, threshold: number[]) => value < threshold[0] || value > threshold[1], diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts index b9f66cfa7a25363..33d1e1897e943af 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts @@ -4,15 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ParamsSchema } from './alert_type_params'; +import { ParamsSchema, Params } from './alert_type_params'; import { runTests } from './lib/core_query_types.test'; -const DefaultParams = { +const DefaultParams: Writable<Partial<Params>> = { index: 'index-name', timeField: 'time-field', aggType: 'count', - window: '5m', - comparator: 'greaterThan', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', threshold: [0], }; @@ -29,28 +31,29 @@ describe('alertType Params validate()', () => { }); it('passes for maximal valid input', async () => { - params.aggType = 'average'; + params.aggType = 'avg'; params.aggField = 'agg-field'; - params.groupField = 'group-field'; - params.groupLimit = 100; + params.groupBy = 'top'; + params.termField = 'group-field'; + params.termSize = 100; expect(validate()).toBeTruthy(); }); it('fails for invalid comparator', async () => { - params.comparator = '[invalid-comparator]'; + params.thresholdComparator = '[invalid-comparator]'; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[comparator]: invalid comparator specified: [invalid-comparator]"` + `"[thresholdComparator]: invalid thresholdComparator specified: [invalid-comparator]"` ); }); it('fails for invalid threshold length', async () => { - params.comparator = 'lessThan'; - params.threshold = [0, 1]; + params.thresholdComparator = '<'; + params.threshold = [0, 1, 2]; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[threshold]: must have one element for the \\"lessThan\\" comparator"` + `"[threshold]: array size is [3], but cannot be greater than [2]"` ); - params.comparator = 'between'; + params.thresholdComparator = 'between'; params.threshold = [0]; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( `"[threshold]: must have two elements for the \\"between\\" comparator"` diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts index d5b83f9f6ad5a00..f83d7fa07cd2a6b 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts @@ -17,7 +17,7 @@ export const ParamsSchema = schema.object( { ...CoreQueryParamsSchemaProperties, // the comparison function to use to determine if the threshold as been met - comparator: schema.string({ validate: validateComparator }), + thresholdComparator: schema.string({ validate: validateComparator }), // the values to use as the threshold; `between` and `notBetween` require // two values, the others require one. threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }), @@ -35,26 +35,16 @@ function validateParams(anyParams: any): string | undefined { const coreQueryValidated = validateCoreQueryBody(anyParams); if (coreQueryValidated) return coreQueryValidated; - const { comparator, threshold }: Params = anyParams; + const { thresholdComparator, threshold }: Params = anyParams; - if (betweenComparators.has(comparator)) { - if (threshold.length === 1) { - return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold2ErrorMessage', { - defaultMessage: '[threshold]: must have two elements for the "{comparator}" comparator', - values: { - comparator, - }, - }); - } - } else { - if (threshold.length === 2) { - return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold1ErrorMessage', { - defaultMessage: '[threshold]: must have one element for the "{comparator}" comparator', - values: { - comparator, - }, - }); - } + if (betweenComparators.has(thresholdComparator) && threshold.length === 1) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold2ErrorMessage', { + defaultMessage: + '[threshold]: must have two elements for the "{thresholdComparator}" comparator', + values: { + thresholdComparator, + }, + }); } } diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts index b4f061adb8f542a..d67d29cacde4238 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts @@ -7,14 +7,16 @@ // tests of common properties on time_series_query and alert_type_params import { ObjectType } from '@kbn/config-schema'; - +import { CoreQueryParams } from './core_query_types'; import { MAX_GROUPS } from '../index'; -const DefaultParams: Record<string, any> = { +const DefaultParams: Writable<Partial<CoreQueryParams>> = { index: 'index-name', timeField: 'time-field', aggType: 'count', - window: '5m', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', }; export function runTests(schema: ObjectType, defaultTypeParams: Record<string, any>): void { @@ -30,28 +32,48 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record<string, a }); it('succeeds with maximal properties', async () => { - params.aggType = 'average'; + params.aggType = 'avg'; + params.aggField = 'agg-field'; + params.groupBy = 'top'; + params.termField = 'group-field'; + params.termSize = 200; + expect(validate()).toBeTruthy(); + + params.index = ['index-name-1', 'index-name-2']; + params.aggType = 'avg'; params.aggField = 'agg-field'; - params.groupField = 'group-field'; - params.groupLimit = 200; + params.groupBy = 'top'; + params.termField = 'group-field'; + params.termSize = 200; expect(validate()).toBeTruthy(); }); it('fails for invalid index', async () => { delete params.index; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[index]: expected value of type [string] but got [undefined]"` + `"[index]: expected at least one defined value but got [undefined]"` ); params.index = 42; - expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[index]: expected value of type [string] but got [number]"` - ); + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(` +"[index]: types that failed validation: +- [index.0]: expected value of type [string] but got [number] +- [index.1]: expected value of type [array] but got [number]" +`); params.index = ''; - expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[index]: value is [] but it must have a minimum length of [1]."` - ); + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(` +"[index]: types that failed validation: +- [index.0]: value is [] but it must have a minimum length of [1]. +- [index.1]: could not parse array value from []" +`); + + params.index = ['', 'a']; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(` +"[index]: types that failed validation: +- [index.0]: expected value of type [string] but got [Array] +- [index.1.0]: value is [] but it must have a minimum length of [1]." +`); }); it('fails for invalid timeField', async () => { @@ -95,58 +117,67 @@ export function runTests(schema: ObjectType, defaultTypeParams: Record<string, a ); }); - it('fails for invalid groupField', async () => { - params.groupField = 42; + it('fails for invalid termField', async () => { + params.groupBy = 'top'; + params.termField = 42; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupField]: expected value of type [string] but got [number]"` + `"[termField]: expected value of type [string] but got [number]"` ); - params.groupField = ''; + params.termField = ''; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupField]: value is [] but it must have a minimum length of [1]."` + `"[termField]: value is [] but it must have a minimum length of [1]."` ); }); - it('fails for invalid groupLimit', async () => { - params.groupLimit = 'foo'; + it('fails for invalid termSize', async () => { + params.groupBy = 'top'; + params.termField = 'fee'; + params.termSize = 'foo'; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupLimit]: expected value of type [number] but got [string]"` + `"[termSize]: expected value of type [number] but got [string]"` ); - params.groupLimit = 0; + params.termSize = MAX_GROUPS + 1; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupLimit]: must be greater than 0"` + `"[termSize]: must be less than or equal to 1000"` ); - params.groupLimit = MAX_GROUPS + 1; + params.termSize = 0; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[groupLimit]: must be less than or equal to 1000"` + `"[termSize]: Value is [0] but it must be equal to or greater than [1]."` ); }); - it('fails for invalid window', async () => { - params.window = 42; + it('fails for invalid timeWindowSize', async () => { + params.timeWindowSize = 'foo'; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[window]: expected value of type [string] but got [number]"` + `"[timeWindowSize]: expected value of type [number] but got [string]"` ); - params.window = 'x'; + params.timeWindowSize = 0; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[window]: invalid duration: \\"x\\""` + `"[timeWindowSize]: Value is [0] but it must be equal to or greater than [1]."` ); }); - it('fails for invalid aggType/aggField', async () => { - params.aggType = 'count'; - params.aggField = 'agg-field-1'; + it('fails for invalid timeWindowUnit', async () => { + params.timeWindowUnit = 42; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[aggField]: must not have a value when [aggType] is \\"count\\""` + `"[timeWindowUnit]: expected value of type [string] but got [number]"` ); - params.aggType = 'average'; + params.timeWindowUnit = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowUnit]: invalid timeWindowUnit: \\"x\\""` + ); + }); + + it('fails for invalid aggType/aggField', async () => { + params.aggType = 'avg'; delete params.aggField; expect(onValidate()).toThrowErrorMatchingInlineSnapshot( - `"[aggField]: must have a value when [aggType] is \\"average\\""` + `"[aggField]: must have a value when [aggType] is \\"avg\\""` ); }); }); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts index 265a70eba4d6b99..6e9c0072bf7b662 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts @@ -10,23 +10,29 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { MAX_GROUPS } from '../index'; -import { parseDuration } from '../../../../../alerting/server'; export const CoreQueryParamsSchemaProperties = { - // name of the index to search - index: schema.string({ minLength: 1 }), + // name of the indices to search + index: schema.oneOf([ + schema.string({ minLength: 1 }), + schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + ]), // field in index used for date/time timeField: schema.string({ minLength: 1 }), // aggregation type aggType: schema.string({ validate: validateAggType }), // aggregation field aggField: schema.maybe(schema.string({ minLength: 1 })), - // group field - groupField: schema.maybe(schema.string({ minLength: 1 })), + // how to group + groupBy: schema.string({ validate: validateGroupBy }), + // field to group on (for groupBy: top) + termField: schema.maybe(schema.string({ minLength: 1 })), // limit on number of groups returned - groupLimit: schema.maybe(schema.number()), + termSize: schema.maybe(schema.number({ min: 1 })), // size of time window for date range aggregations - window: schema.string({ validate: validateDuration }), + timeWindowSize: schema.number({ min: 1 }), + // units of time window for date range aggregations + timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }), }; const CoreQueryParamsSchema = schema.object(CoreQueryParamsSchemaProperties); @@ -37,17 +43,7 @@ export type CoreQueryParams = TypeOf<typeof CoreQueryParamsSchema>; // above. // Using direct type not allowed, circular reference, so body is typed to any. export function validateCoreQueryBody(anyParams: any): string | undefined { - const { aggType, aggField, groupLimit }: CoreQueryParams = anyParams; - - if (aggType === 'count' && aggField) { - return i18n.translate('xpack.alertingBuiltins.indexThreshold.aggTypeNotEmptyErrorMessage', { - defaultMessage: '[aggField]: must not have a value when [aggType] is "{aggType}"', - values: { - aggType, - }, - }); - } - + const { aggType, aggField, groupBy, termField, termSize }: CoreQueryParams = anyParams; if (aggType !== 'count' && !aggField) { return i18n.translate('xpack.alertingBuiltins.indexThreshold.aggTypeRequiredErrorMessage', { defaultMessage: '[aggField]: must have a value when [aggType] is "{aggType}"', @@ -57,21 +53,23 @@ export function validateCoreQueryBody(anyParams: any): string | undefined { }); } - // schema.number doesn't seem to check the max value ... - if (groupLimit != null) { - if (groupLimit <= 0) { - return i18n.translate( - 'xpack.alertingBuiltins.indexThreshold.invalidGroupMinimumErrorMessage', - { - defaultMessage: '[groupLimit]: must be greater than 0', - } - ); + // check grouping + if (groupBy === 'top') { + if (termField == null) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.termFieldRequiredErrorMessage', { + defaultMessage: '[termField]: termField required when [groupBy] is top', + }); + } + if (termSize == null) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.termSizeRequiredErrorMessage', { + defaultMessage: '[termSize]: termSize required when [groupBy] is top', + }); } - if (groupLimit > MAX_GROUPS) { + if (termSize > MAX_GROUPS) { return i18n.translate( - 'xpack.alertingBuiltins.indexThreshold.invalidGroupMaximumErrorMessage', + 'xpack.alertingBuiltins.indexThreshold.invalidTermSizeMaximumErrorMessage', { - defaultMessage: '[groupLimit]: must be less than or equal to {maxGroups}', + defaultMessage: '[termSize]: must be less than or equal to {maxGroups}', values: { maxGroups: MAX_GROUPS, }, @@ -81,10 +79,12 @@ export function validateCoreQueryBody(anyParams: any): string | undefined { } } -const AggTypes = new Set(['count', 'average', 'min', 'max', 'sum']); +const AggTypes = new Set(['count', 'avg', 'min', 'max', 'sum']); function validateAggType(aggType: string): string | undefined { - if (AggTypes.has(aggType)) return; + if (AggTypes.has(aggType)) { + return; + } return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidAggTypeErrorMessage', { defaultMessage: 'invalid aggType: "{aggType}"', @@ -94,15 +94,33 @@ function validateAggType(aggType: string): string | undefined { }); } -export function validateDuration(duration: string): string | undefined { - try { - parseDuration(duration); - } catch (err) { - return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidDurationErrorMessage', { - defaultMessage: 'invalid duration: "{duration}"', +export function validateGroupBy(groupBy: string): string | undefined { + if (groupBy === 'all' || groupBy === 'top') { + return; + } + + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidGroupByErrorMessage', { + defaultMessage: 'invalid groupBy: "{groupBy}"', + values: { + groupBy, + }, + }); +} + +const TimeWindowUnits = new Set(['s', 'm', 'h', 'd']); + +export function validateTimeWindowUnits(timeWindowUnit: string): string | undefined { + if (TimeWindowUnits.has(timeWindowUnit)) { + return; + } + + return i18n.translate( + 'xpack.alertingBuiltins.indexThreshold.invalidTimeWindowUnitsErrorMessage', + { + defaultMessage: 'invalid timeWindowUnit: "{timeWindowUnit}"', values: { - duration, + timeWindowUnit, }, - }); - } + } + ); } diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts index 1955cdfa4cea62c..d40df4c91998ffd 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts @@ -9,28 +9,30 @@ import { loggingServiceMock } from '../../../../../../../src/core/server/mocks'; import { coreMock } from '../../../../../../../src/core/server/mocks'; import { AlertingBuiltinsPlugin } from '../../../plugin'; -import { TimeSeriesQueryParameters, TimeSeriesResult } from './time_series_query'; +import { TimeSeriesQueryParameters, TimeSeriesResult, TimeSeriesQuery } from './time_series_query'; -type TimeSeriesQuery = (params: TimeSeriesQueryParameters) => Promise<TimeSeriesResult>; +type TimeSeriesQueryFn = (query: TimeSeriesQueryParameters) => Promise<TimeSeriesResult>; -const DefaultQueryParams = { +const DefaultQueryParams: TimeSeriesQuery = { index: 'index-name', timeField: 'time-field', aggType: 'count', aggField: undefined, - window: '5m', + timeWindowSize: 5, + timeWindowUnit: 'm', dateStart: undefined, dateEnd: undefined, interval: undefined, - groupField: undefined, - groupLimit: undefined, + groupBy: 'all', + termField: undefined, + termSize: undefined, }; describe('timeSeriesQuery', () => { let params: TimeSeriesQueryParameters; const mockCallCluster = jest.fn(); - let timeSeriesQuery: TimeSeriesQuery; + let timeSeriesQueryFn: TimeSeriesQueryFn; beforeEach(async () => { // rather than use the function from an import, retrieve it from the plugin @@ -38,26 +40,26 @@ describe('timeSeriesQuery', () => { const plugin = new AlertingBuiltinsPlugin(context); const coreStart = coreMock.createStart(); const service = await plugin.start(coreStart); - timeSeriesQuery = service.indexThreshold.timeSeriesQuery; + timeSeriesQueryFn = service.indexThreshold.timeSeriesQuery; mockCallCluster.mockReset(); params = { logger: loggingServiceMock.create().get(), callCluster: mockCallCluster, - query: { ...DefaultQueryParams }, + query: DefaultQueryParams, }; }); it('fails as expected when the callCluster call fails', async () => { mockCallCluster.mockRejectedValue(new Error('woopsie')); - expect(timeSeriesQuery(params)).rejects.toThrowErrorMatchingInlineSnapshot( + expect(timeSeriesQueryFn(params)).rejects.toThrowErrorMatchingInlineSnapshot( `"error running search"` ); }); it('fails as expected when the query params are invalid', async () => { params.query = { ...params.query, dateStart: 'x' }; - expect(timeSeriesQuery(params)).rejects.toThrowErrorMatchingInlineSnapshot( + expect(timeSeriesQueryFn(params)).rejects.toThrowErrorMatchingInlineSnapshot( `"invalid date format for dateStart: \\"x\\""` ); }); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts index 8ea2a7dd1dcc58d..a4f64c0f37f41a6 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts @@ -21,8 +21,17 @@ export async function timeSeriesQuery( params: TimeSeriesQueryParameters ): Promise<TimeSeriesResult> { const { logger, callCluster, query: queryParams } = params; - const { index, window, interval, timeField, dateStart, dateEnd } = queryParams; - + const { + index, + timeWindowSize, + timeWindowUnit, + interval, + timeField, + dateStart, + dateEnd, + } = queryParams; + + const window = `${timeWindowSize}${timeWindowUnit}`; const dateRangeInfo = getDateRangeInfo({ dateStart, dateEnd, window, interval }); // core query @@ -51,10 +60,10 @@ export async function timeSeriesQuery( }; // add the aggregations - const { aggType, aggField, groupField, groupLimit } = queryParams; + const { aggType, aggField, termField, termSize } = queryParams; const isCountAgg = aggType === 'count'; - const isGroupAgg = !!groupField; + const isGroupAgg = !!termField; let aggParent = esQuery.body; @@ -63,8 +72,8 @@ export async function timeSeriesQuery( aggParent.aggs = { groupAgg: { terms: { - field: groupField, - size: groupLimit || DEFAULT_GROUPS, + field: termField, + size: termSize || DEFAULT_GROUPS, }, }, }; @@ -83,11 +92,10 @@ export async function timeSeriesQuery( aggParent = aggParent.aggs.dateAgg; // finally, the metric aggregation, if requested - const actualAggType = aggType === 'average' ? 'avg' : aggType; if (!isCountAgg) { aggParent.aggs = { metricAgg: { - [actualAggType]: { + [aggType]: { field: aggField, }, }, diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts index d69d48efcdf6b6a..fcbd49b26ffd048 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts @@ -4,14 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TimeSeriesQuerySchema } from './time_series_types'; +import { TimeSeriesQuerySchema, TimeSeriesQuery } from './time_series_types'; import { runTests } from './core_query_types.test'; -const DefaultParams = { +const DefaultParams: Writable<Partial<TimeSeriesQuery>> = { index: 'index-name', timeField: 'time-field', aggType: 'count', - window: '5m', + groupBy: 'all', + timeWindowSize: 5, + timeWindowUnit: 'm', }; describe('TimeSeriesParams validate()', () => { @@ -27,10 +29,11 @@ describe('TimeSeriesParams validate()', () => { }); it('passes for maximal valid input', async () => { - params.aggType = 'average'; + params.aggType = 'avg'; params.aggField = 'agg-field'; - params.groupField = 'group-field'; - params.groupLimit = 100; + params.groupBy = 'top'; + params.termField = 'group-field'; + params.termSize = 100; params.dateStart = new Date().toISOString(); params.dateEnd = new Date().toISOString(); params.interval = '1s'; diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts index a727e67c621d42c..6cb21a1581113fc 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts @@ -12,11 +12,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { parseDuration } from '../../../../../alerting/server'; import { MAX_INTERVALS } from '../index'; -import { - CoreQueryParamsSchemaProperties, - validateCoreQueryBody, - validateDuration, -} from './core_query_types'; +import { CoreQueryParamsSchemaProperties, validateCoreQueryBody } from './core_query_types'; import { getTooManyIntervalsErrorMessage, getDateStartAfterDateEndErrorMessage, @@ -104,3 +100,16 @@ function validateDate(dateString: string): string | undefined { }); } } + +export function validateDuration(duration: string): string | undefined { + try { + parseDuration(duration); + } catch (err) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidDurationErrorMessage', { + defaultMessage: 'invalid duration: "{duration}"', + values: { + duration, + }, + }); + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index f7d2b8f60157ff3..a34a032f833b288 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -46,8 +46,6 @@ const DEFAULT_VALUES = { THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, TIME_WINDOW_SIZE: 5, TIME_WINDOW_UNIT: 'm', - TRIGGER_INTERVAL_SIZE: 1, - TRIGGER_INTERVAL_UNIT: 'm', THRESHOLD: [1000, 5000], GROUP_BY: 'all', }; @@ -141,7 +139,6 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<IndexThr thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, - triggerIntervalUnit: DEFAULT_VALUES.TRIGGER_INTERVAL_UNIT, groupBy: DEFAULT_VALUES.GROUP_BY, threshold: DEFAULT_VALUES.THRESHOLD, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts index e38814931107563..a94d2319d6e4ddc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts @@ -11,7 +11,7 @@ import { builtInGroupByTypes, builtInAggregationTypes } from '../../../../common export function getAlertType(): AlertTypeModel { return { - id: 'threshold', + id: '.index-threshold', name: 'Index Threshold', iconClass: 'alert', alertParamsExpression: IndexThresholdAlertTypeExpression, diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx index 01e454187d39803..7ca32764dbdfddf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx @@ -61,6 +61,7 @@ export const GroupByExpression = ({ const groupByTypes = customGroupByTypes ?? builtInGroupByTypes; const [groupByPopoverOpen, setGroupByPopoverOpen] = useState(false); const MIN_TERM_SIZE = 1; + const MAX_TERM_SIZE = 1000; const firstFieldOption = { text: i18n.translate( 'xpack.triggersActionsUI.common.expressionItems.groupByType.timeFieldOptionLabel', @@ -159,6 +160,7 @@ export const GroupByExpression = ({ onChangeSelectedTermSize(termSizeVal); }} min={MIN_TERM_SIZE} + max={MAX_TERM_SIZE} /> </EuiFormRow> </EuiFlexItem> diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts index 9c1a58760be7930..8a6e89009b850d2 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts @@ -25,8 +25,8 @@ const INTERVAL_MINUTES = 1; const INTERVAL_DURATION = `${INTERVAL_MINUTES}m`; const INTERVAL_MILLIS = INTERVAL_MINUTES * 60 * 1000; -const WINDOW_MINUTES = 5; -const WINDOW_DURATION = `${WINDOW_MINUTES}m`; +const WINDOW_DURATION_SIZE = 5; +const WINDOW_DURATION_UNITS = 'm'; // interesting dates pertaining to docs and intervals const START_DATE_PLUS_YEAR = `2021-${START_DATE_MM_DD_HH_MM_SS_MS}`; @@ -154,7 +154,7 @@ export default function queryDataEndpointTests({ getService }: FtrProviderContex it('should return correct count for all intervals, grouped', async () => { const query = getQueryBody({ - groupField: 'group', + termField: 'group', dateStart: START_DATE_MINUS_2INTERVALS, dateEnd: START_DATE_MINUS_0INTERVALS, }); @@ -185,9 +185,11 @@ export default function queryDataEndpointTests({ getService }: FtrProviderContex it('should return correct average for all intervals, grouped', async () => { const query = getQueryBody({ - aggType: 'average', + aggType: 'avg', aggField: 'testedValue', - groupField: 'group', + groupBy: 'top', + termField: 'group', + termSize: 100, dateStart: START_DATE_MINUS_2INTERVALS, dateEnd: START_DATE_MINUS_0INTERVALS, }); @@ -266,11 +268,13 @@ function getQueryBody(body: Partial<TimeSeriesQuery> = {}): TimeSeriesQuery { timeField: 'date', aggType: 'count', aggField: undefined, - groupField: undefined, - groupLimit: undefined, + groupBy: 'all', + termField: undefined, + termSize: undefined, dateStart: START_DATE_MINUS_0INTERVALS, dateEnd: undefined, - window: WINDOW_DURATION, + timeWindowSize: WINDOW_DURATION_SIZE, + timeWindowUnit: WINDOW_DURATION_UNITS, interval: INTERVAL_DURATION, }; return Object.assign({}, defaults, body); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 84081309c18d9ec..60ba03df6a9a8dd 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -45,16 +45,21 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('should create an alert', async () => { const alertName = generateUniqueKey(); - await pageObjects.triggersActionsUI.clickCreateAlertButton(); - const nameInput = await testSubjects.find('alertNameInput'); await nameInput.click(); await nameInput.clearValue(); await nameInput.type(alertName); - - await testSubjects.click('threshold-SelectOption'); - + await testSubjects.click('.index-threshold-SelectOption'); + await testSubjects.click('selectIndexExpression'); + const comboBox = await find.byCssSelector('#indexSelectSearchBox'); + await comboBox.click(); + await comboBox.type('k'); + const filterSelectItem = await find.byCssSelector(`.euiFilterSelectItem`); + await filterSelectItem.click(); + await testSubjects.click('thresholdAlertTimeFieldSelect'); + const fieldOptions = await find.allByCssSelector('#thresholdTimeField option'); + await fieldOptions[1].click(); await testSubjects.click('.slack-ActionTypeSelectOption'); await testSubjects.click('createActionConnectorButton'); const connectorNameInput = await testSubjects.find('nameInput'); @@ -62,28 +67,32 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await connectorNameInput.clearValue(); const connectorName = generateUniqueKey(); await connectorNameInput.type(connectorName); - const slackWebhookUrlInput = await testSubjects.find('slackWebhookUrlInput'); await slackWebhookUrlInput.click(); await slackWebhookUrlInput.clearValue(); await slackWebhookUrlInput.type('https://test'); - await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); - const loggingMessageInput = await testSubjects.find('slackMessageTextArea'); await loggingMessageInput.click(); await loggingMessageInput.clearValue(); await loggingMessageInput.type('test message'); - - await testSubjects.click('slackAddVariableButton'); - const variableMenuButton = await testSubjects.find('variableMenuButton-0'); - await variableMenuButton.click(); - - await testSubjects.click('selectIndexExpression'); - - await find.clickByCssSelector('[data-test-subj="cancelSaveAlertButton"]'); - - // TODO: implement saving to the server, when threshold API will be ready + // TODO: uncomment variables test when server API will be ready + // await testSubjects.click('slackAddVariableButton'); + // const variableMenuButton = await testSubjects.find('variableMenuButton-0'); + // await variableMenuButton.click(); + await find.clickByCssSelector('[data-test-subj="saveAlertButton"]'); + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Saved '${alertName}'`); + await pageObjects.triggersActionsUI.searchAlerts(alertName); + const searchResultsAfterEdit = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResultsAfterEdit).to.eql([ + { + name: alertName, + tagsText: '', + alertType: 'Index Threshold', + interval: '1m', + }, + ]); }); it('should search for alert', async () => { From 1995a05c1609df9215878e29c99d238f8b7d96d3 Mon Sep 17 00:00:00 2001 From: Andrew Cholakian <andrew@andrewvc.com> Date: Tue, 3 Mar 2020 08:04:35 -0600 Subject: [PATCH 18/22] [Uptime] Refactor header (#58836) Cleanup implementation of the header to let pages embed the header rather than the weird sort of control the header works with today. Also uses kibana context in a way that makes more sense, and provide a path forward for #53550 since that will need to add a new header type (and some buttons next to the picker). Fixes #58835 --- .../plugins/uptime/public/breadcrumbs.ts | 19 - .../connected/pages/page_header_container.tsx | 16 - .../__snapshots__/monitor.test.tsx.snap | 2 +- .../__snapshots__/page_header.test.tsx.snap | 1007 +---------------- .../pages/__tests__/page_header.test.tsx | 175 +-- .../plugins/uptime/public/pages/monitor.tsx | 51 +- .../plugins/uptime/public/pages/overview.tsx | 8 + .../uptime/public/pages/page_header.tsx | 86 +- .../plugins/uptime/public/uptime_app.tsx | 7 +- 9 files changed, 155 insertions(+), 1216 deletions(-) delete mode 100644 x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx diff --git a/x-pack/legacy/plugins/uptime/public/breadcrumbs.ts b/x-pack/legacy/plugins/uptime/public/breadcrumbs.ts index ff0dca3887ff2cc..41bc2aa2588073d 100644 --- a/x-pack/legacy/plugins/uptime/public/breadcrumbs.ts +++ b/x-pack/legacy/plugins/uptime/public/breadcrumbs.ts @@ -3,22 +3,3 @@ * 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 { ChromeBreadcrumb } from 'src/core/public'; - -const makeOverviewBreadcrumb = (search?: string): ChromeBreadcrumb => ({ - text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { - defaultMessage: 'Uptime', - }), - href: `#/${search ? search : ''}`, -}); - -export const getOverviewPageBreadcrumbs = (search?: string): ChromeBreadcrumb[] => [ - makeOverviewBreadcrumb(search), -]; - -export const getMonitorPageBreadcrumb = (name: string, search?: string): ChromeBreadcrumb[] => [ - makeOverviewBreadcrumb(search), - { text: name }, -]; diff --git a/x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx b/x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx deleted file mode 100644 index 9429b87061ff7c7..000000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/connected/pages/page_header_container.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { connect } from 'react-redux'; -import { selectSelectedMonitor } from '../../../state/selectors'; -import { AppState } from '../../../state'; -import { PageHeaderComponent } from '../../../pages/page_header'; - -const mapStateToProps = (state: AppState) => ({ - monitorStatus: selectSelectedMonitor(state), -}); - -export const PageHeader = connect(mapStateToProps, null)(PageHeaderComponent); diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap index 6064caa868bf82f..f637af397bbeb50 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/monitor.test.tsx.snap @@ -51,6 +51,6 @@ exports[`MonitorPage shallow renders expected elements for valid props 1`] = ` } } > - <MonitorPage /> + <Connect(MonitorPageComponent) /> </ContextProvider> `; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index 2563b15eed5d5d9..58d98af5d6b14b7 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -1,836 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`PageHeaderComponent mount expected page title for valid monitor route 1`] = ` -<Router - history={ - Object { - "action": "POP", - "block": [Function], - "canGo": [Function], - "createHref": [Function], - "entries": Array [ - Object { - "hash": "", - "key": "TestKeyForTesting", - "pathname": "/monitor/ZWxhc3RpYy1jbw==", - "search": "", - "state": undefined, - }, - ], - "go": [Function], - "goBack": [Function], - "goForward": [Function], - "index": 0, - "length": 1, - "listen": [Function], - "location": Object { - "hash": "", - "key": "TestKeyForTesting", - "pathname": "/monitor/ZWxhc3RpYy1jbw==", - "search": "", - "state": undefined, - }, - "push": [Function], - "replace": [Function], - } - } - intl={ - Object { - "defaultFormats": Object {}, - "defaultLocale": "en", - "formatDate": [Function], - "formatHTMLMessage": [Function], - "formatMessage": [Function], - "formatNumber": [Function], - "formatPlural": [Function], - "formatRelative": [Function], - "formatTime": [Function], - "formats": Object { - "date": Object { - "full": Object { - "day": "numeric", - "month": "long", - "weekday": "long", - "year": "numeric", - }, - "long": Object { - "day": "numeric", - "month": "long", - "year": "numeric", - }, - "medium": Object { - "day": "numeric", - "month": "short", - "year": "numeric", - }, - "short": Object { - "day": "numeric", - "month": "numeric", - "year": "2-digit", - }, - }, - "number": Object { - "currency": Object { - "style": "currency", - }, - "percent": Object { - "style": "percent", - }, - }, - "relative": Object { - "days": Object { - "units": "day", - }, - "hours": Object { - "units": "hour", - }, - "minutes": Object { - "units": "minute", - }, - "months": Object { - "units": "month", - }, - "seconds": Object { - "units": "second", - }, - "years": Object { - "units": "year", - }, - }, - "time": Object { - "full": Object { - "hour": "numeric", - "minute": "numeric", - "second": "numeric", - "timeZoneName": "short", - }, - "long": Object { - "hour": "numeric", - "minute": "numeric", - "second": "numeric", - "timeZoneName": "short", - }, - "medium": Object { - "hour": "numeric", - "minute": "numeric", - "second": "numeric", - }, - "short": Object { - "hour": "numeric", - "minute": "numeric", - }, - }, - }, - "formatters": Object { - "getDateTimeFormat": [Function], - "getMessageFormat": [Function], - "getNumberFormat": [Function], - "getPluralFormat": [Function], - "getRelativeFormat": [Function], - }, - "locale": "en", - "messages": Object {}, - "now": [Function], - "onError": [Function], - "textComponent": Symbol(react.fragment), - "timeZone": null, - } - } -> - <Route - path="/monitor/:monitorId?" - > - <PageHeaderComponent - monitorStatus={ - Object { - "ecs": Object { - "version": "1.4.0", - }, - "http": Object { - "response": Object { - "body": Object { - "bytes": 2092041, - "hash": "5d970606a6be810ae5d37115c4807fdd07ba4c3e407924ee5297e172d2efb3dc", - }, - "status_code": 200, - }, - "rtt": Object { - "content": Object { - "us": 1457663, - }, - "response_header": Object { - "us": 340175, - }, - "total": Object { - "us": 2030012, - }, - "validate": Object { - "us": 1797839, - }, - "write_request": Object { - "us": 38, - }, - }, - }, - "id": "elastic-co", - "monitor": Object { - "check_group": "2a017afa-4736-11ea-b3d0-acde48001122", - "duration": Object { - "us": 2030035, - }, - "id": "elastic-co", - "ip": "2a04:4e42:3::729", - "name": "elastic", - "status": "up", - "type": "http", - }, - "observer": Object { - "geo": Object { - "location": "37.422994, -122.083666", - "name": "US-West", - }, - }, - "resolve": Object { - "ip": "2a04:4e42:3::729", - "rtt": Object { - "us": 2102, - }, - }, - "tcp": Object { - "rtt": Object { - "connect": Object { - "us": 174982, - }, - }, - }, - "timestamp": "2020-02-04T10:07:42.142Z", - "tls": Object { - "certificate_not_valid_after": "2020-07-16T03:15:39.000Z", - "certificate_not_valid_before": "2019-08-16T01:40:25.000Z", - "rtt": Object { - "handshake": Object { - "us": 57115, - }, - }, - }, - "url": Object { - "domain": "www.elastic.co", - "full": "https://www.elastic.co", - "port": 443, - "scheme": "https", - }, - } - } - setBreadcrumbs={ - [MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "href": "#/?", - "text": "Uptime", - }, - Object { - "text": "https://www.elastic.co", - }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - } - } - > - <EuiFlexGroup - alignItems="center" - gutterSize="s" - justifyContent="spaceBetween" - wrap={true} - > - <div - className="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap" - > - <EuiFlexItem> - <div - className="euiFlexItem" - > - <EuiTitle> - <h1 - className="euiTitle euiTitle--medium" - > - https://www.elastic.co - </h1> - </EuiTitle> - </div> - </EuiFlexItem> - <EuiFlexItem - grow={false} - > - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - > - <UptimeDatePicker> - <EuiSuperDatePicker - commonlyUsedRanges={ - Array [ - Object { - "end": "now", - "label": "Today", - "start": "now/d", - }, - Object { - "end": "now", - "label": "Week to date", - "start": "now/w", - }, - Object { - "end": "now", - "label": "Month to date", - "start": "now/M", - }, - Object { - "end": "now", - "label": "Year to date", - "start": "now/y", - }, - ] - } - dateFormat="MMM D, YYYY @ HH:mm:ss.SSS" - end="now" - isAutoRefreshOnly={false} - isDisabled={false} - isPaused={false} - onRefresh={[Function]} - onRefreshChange={[Function]} - onTimeChange={[Function]} - recentlyUsedRanges={Array []} - refreshInterval={60000} - showUpdateButton={true} - start="now-15m" - timeFormat="HH:mm" - > - <EuiFlexGroup - className="euiSuperDatePicker__flexWrapper" - gutterSize="s" - responsive={false} - > - <div - className="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--directionRow euiSuperDatePicker__flexWrapper" - > - <EuiFlexItem> - <div - className="euiFlexItem" - > - <EuiFormControlLayout - className="euiSuperDatePicker" - isDisabled={false} - prepend={ - <EuiQuickSelectPopover - applyRefreshInterval={[Function]} - applyTime={[Function]} - commonlyUsedRanges={ - Array [ - Object { - "end": "now", - "label": "Today", - "start": "now/d", - }, - Object { - "end": "now", - "label": "Week to date", - "start": "now/w", - }, - Object { - "end": "now", - "label": "Month to date", - "start": "now/M", - }, - Object { - "end": "now", - "label": "Year to date", - "start": "now/y", - }, - ] - } - dateFormat="MMM D, YYYY @ HH:mm:ss.SSS" - end="now" - isAutoRefreshOnly={false} - isDisabled={false} - isPaused={false} - recentlyUsedRanges={Array []} - refreshInterval={60000} - start="now-15m" - /> - } - > - <div - className="euiFormControlLayout euiFormControlLayout--group euiSuperDatePicker" - > - <EuiQuickSelectPopover - applyRefreshInterval={[Function]} - applyTime={[Function]} - className="euiFormControlLayout__prepend" - commonlyUsedRanges={ - Array [ - Object { - "end": "now", - "label": "Today", - "start": "now/d", - }, - Object { - "end": "now", - "label": "Week to date", - "start": "now/w", - }, - Object { - "end": "now", - "label": "Month to date", - "start": "now/M", - }, - Object { - "end": "now", - "label": "Year to date", - "start": "now/y", - }, - ] - } - dateFormat="MMM D, YYYY @ HH:mm:ss.SSS" - end="now" - isAutoRefreshOnly={false} - isDisabled={false} - isPaused={false} - key="0/.0" - recentlyUsedRanges={Array []} - refreshInterval={60000} - start="now-15m" - > - <EuiPopover - anchorClassName="euiQuickSelectPopover__anchor" - anchorPosition="downLeft" - button={ - <EuiButtonEmpty - aria-label="Date quick select" - className="euiFormControlLayout__prepend" - data-test-subj="superDatePickerToggleQuickMenuButton" - iconSide="right" - iconType="arrowDown" - isDisabled={false} - onClick={[Function]} - size="xs" - textProps={ - Object { - "className": "euiQuickSelectPopover__buttonText", - } - } - > - <EuiIcon - type="clock" - /> - </EuiButtonEmpty> - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="QuickSelectPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="m" - > - <EuiOutsideClickDetector - isDisabled={true} - onOutsideClick={[Function]} - > - <div - className="euiPopover euiPopover--anchorDownLeft" - id="QuickSelectPopover" - onKeyDown={[Function]} - onMouseDown={[Function]} - onMouseUp={[Function]} - onTouchEnd={[Function]} - onTouchStart={[Function]} - > - <div - className="euiPopover__anchor euiQuickSelectPopover__anchor" - > - <EuiButtonEmpty - aria-label="Date quick select" - className="euiFormControlLayout__prepend" - data-test-subj="superDatePickerToggleQuickMenuButton" - iconSide="right" - iconType="arrowDown" - isDisabled={false} - onClick={[Function]} - size="xs" - textProps={ - Object { - "className": "euiQuickSelectPopover__buttonText", - } - } - > - <button - aria-label="Date quick select" - className="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall euiButtonEmpty--iconRight euiFormControlLayout__prepend" - data-test-subj="superDatePickerToggleQuickMenuButton" - disabled={false} - onClick={[Function]} - type="button" - > - <span - className="euiButtonEmpty__content" - > - <EuiIcon - aria-hidden="true" - className="euiButtonEmpty__icon" - size="m" - type="arrowDown" - > - <EuiIconEmpty - aria-hidden={true} - className="euiIcon euiIcon--medium euiIcon-isLoading euiButtonEmpty__icon" - focusable="false" - role="img" - style={null} - > - <svg - aria-hidden={true} - className="euiIcon euiIcon--medium euiIcon-isLoading euiButtonEmpty__icon" - focusable="false" - height={16} - role="img" - style={null} - viewBox="0 0 16 16" - width={16} - xmlns="http://www.w3.org/2000/svg" - /> - </EuiIconEmpty> - </EuiIcon> - <span - className="euiButtonEmpty__text euiQuickSelectPopover__buttonText" - > - <EuiIcon - type="clock" - > - <EuiIconEmpty - aria-hidden={true} - className="euiIcon euiIcon--medium euiIcon-isLoading" - focusable="false" - role="img" - style={null} - > - <svg - aria-hidden={true} - className="euiIcon euiIcon--medium euiIcon-isLoading" - focusable="false" - height={16} - role="img" - style={null} - viewBox="0 0 16 16" - width={16} - xmlns="http://www.w3.org/2000/svg" - /> - </EuiIconEmpty> - </EuiIcon> - </span> - </span> - </button> - </EuiButtonEmpty> - </div> - </div> - </EuiOutsideClickDetector> - </EuiPopover> - </EuiQuickSelectPopover> - <div - className="euiFormControlLayout__childrenWrapper" - > - <EuiDatePickerRange - className="euiDatePickerRange--inGroup" - endDateControl={<div />} - iconType={false} - isCustom={true} - startDateControl={<div />} - > - <div - className="euiDatePickerRange euiDatePickerRange--inGroup" - > - <button - className="euiSuperDatePicker__prettyFormat" - data-test-subj="superDatePickerShowDatesButton" - disabled={false} - onClick={[Function]} - > - Last 15 minutes - <span - className="euiSuperDatePicker__prettyFormatLink" - > - <EuiI18n - default="Show dates" - token="euiSuperDatePicker.showDatesButtonLabel" - > - Show dates - </EuiI18n> - </span> - </button> - </div> - </EuiDatePickerRange> - <EuiFormControlLayoutIcons /> - </div> - </div> - </EuiFormControlLayout> - </div> - </EuiFlexItem> - <EuiFlexItem - grow={false} - > - <div - className="euiFlexItem euiFlexItem--flexGrowZero" - > - <EuiSuperUpdateButton - data-test-subj="superDatePickerApplyTimeButton" - isDisabled={false} - isLoading={false} - needsUpdate={false} - onClick={[Function]} - > - <EuiToolTip - delay="regular" - position="bottom" - > - <span - className="euiToolTipAnchor" - onMouseOut={[Function]} - onMouseOver={[Function]} - > - <EuiButton - className="euiSuperUpdateButton" - color="primary" - data-test-subj="superDatePickerApplyTimeButton" - fill={true} - iconType="refresh" - isDisabled={false} - isLoading={false} - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - textProps={ - Object { - "className": "euiSuperUpdateButton__text", - } - } - > - <button - className="euiButton euiButton--primary euiSuperUpdateButton euiButton--fill" - data-test-subj="superDatePickerApplyTimeButton" - disabled={false} - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - type="button" - > - <span - className="euiButton__content" - > - <EuiIcon - aria-hidden="true" - className="euiButton__icon" - size="m" - type="refresh" - > - <EuiIconEmpty - aria-hidden={true} - className="euiIcon euiIcon--medium euiIcon-isLoading euiButton__icon" - focusable="false" - role="img" - style={null} - > - <svg - aria-hidden={true} - className="euiIcon euiIcon--medium euiIcon-isLoading euiButton__icon" - focusable="false" - height={16} - role="img" - style={null} - viewBox="0 0 16 16" - width={16} - xmlns="http://www.w3.org/2000/svg" - /> - </EuiIconEmpty> - </EuiIcon> - <span - className="euiButton__text euiSuperUpdateButton__text" - > - <EuiI18n - default="Refresh" - token="euiSuperUpdateButton.refreshButtonLabel" - > - Refresh - </EuiI18n> - </span> - </span> - </button> - </EuiButton> - </span> - </EuiToolTip> - </EuiSuperUpdateButton> - </div> - </EuiFlexItem> - </div> - </EuiFlexGroup> - </EuiSuperDatePicker> - </UptimeDatePicker> - </div> - </EuiFlexItem> - </div> - </EuiFlexGroup> - <EuiSpacer - size="s" - > - <div - className="euiSpacer euiSpacer--s" - /> - </EuiSpacer> - </PageHeaderComponent> - </Route> -</Router> -`; - -exports[`PageHeaderComponent renders expected elements for valid props 1`] = ` -Array [ - <div - class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap" - > - <div - class="euiFlexItem" - > - <h1 - class="euiTitle euiTitle--medium" - > - Overview - </h1> - </div> - <div - class="euiFlexItem euiFlexItem--flexGrowZero" - > - <div - class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--directionRow euiSuperDatePicker__flexWrapper" - > - <div - class="euiFlexItem" - > - <div - class="euiFormControlLayout euiFormControlLayout--group euiSuperDatePicker" - > - <div - class="euiPopover euiPopover--anchorDownLeft" - id="QuickSelectPopover" - > - <div - class="euiPopover__anchor euiQuickSelectPopover__anchor" - > - <button - aria-label="Date quick select" - class="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall euiButtonEmpty--iconRight euiFormControlLayout__prepend" - data-test-subj="superDatePickerToggleQuickMenuButton" - type="button" - > - <span - class="euiButtonEmpty__content" - > - <svg - aria-hidden="true" - class="euiIcon euiIcon--medium euiIcon-isLoading euiButtonEmpty__icon" - focusable="false" - height="16" - role="img" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - /> - <span - class="euiButtonEmpty__text euiQuickSelectPopover__buttonText" - > - <svg - aria-hidden="true" - class="euiIcon euiIcon--medium euiIcon-isLoading" - focusable="false" - height="16" - role="img" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - /> - </span> - </span> - </button> - </div> - </div> - <div - class="euiFormControlLayout__childrenWrapper" - > - <div - class="euiDatePickerRange euiDatePickerRange--inGroup" - > - <button - class="euiSuperDatePicker__prettyFormat" - data-test-subj="superDatePickerShowDatesButton" - > - Last 15 minutes - <span - class="euiSuperDatePicker__prettyFormatLink" - > - Show dates - </span> - </button> - </div> - </div> - </div> - </div> - <div - class="euiFlexItem euiFlexItem--flexGrowZero" - > - <span - class="euiToolTipAnchor" - > - <button - class="euiButton euiButton--primary euiSuperUpdateButton euiButton--fill" - data-test-subj="superDatePickerApplyTimeButton" - type="button" - > - <span - class="euiButton__content" - > - <svg - aria-hidden="true" - class="euiIcon euiIcon--medium euiIcon-isLoading euiButton__icon" - focusable="false" - height="16" - role="img" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - /> - <span - class="euiButton__text euiSuperUpdateButton__text" - > - Refresh - </span> - </span> - </button> - </span> - </div> - </div> - </div> - </div>, - <div - class="euiSpacer euiSpacer--s" - />, -] -`; - -exports[`PageHeaderComponent renders expected title for valid monitor route 1`] = ` +exports[`PageHeader shallow renders with breadcrumbs and the date picker: page_header_with_date_picker 1`] = ` Array [ <div class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap" @@ -841,7 +11,7 @@ Array [ <h1 class="euiTitle euiTitle--medium" > - https://www.elastic.co + TestingHeading </h1> </div> <div @@ -963,7 +133,7 @@ Array [ ] `; -exports[`PageHeaderComponent renders expected title for valid overview route 1`] = ` +exports[`PageHeader shallow renders with breadcrumbs without the date picker: page_header_no_date_picker 1`] = ` Array [ <div class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap" @@ -974,181 +144,12 @@ Array [ <h1 class="euiTitle euiTitle--medium" > - Overview + TestingHeading </h1> </div> - <div - class="euiFlexItem euiFlexItem--flexGrowZero" - > - <div - class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--directionRow euiSuperDatePicker__flexWrapper" - > - <div - class="euiFlexItem" - > - <div - class="euiFormControlLayout euiFormControlLayout--group euiSuperDatePicker" - > - <div - class="euiPopover euiPopover--anchorDownLeft" - id="QuickSelectPopover" - > - <div - class="euiPopover__anchor euiQuickSelectPopover__anchor" - > - <button - aria-label="Date quick select" - class="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--xSmall euiButtonEmpty--iconRight euiFormControlLayout__prepend" - data-test-subj="superDatePickerToggleQuickMenuButton" - type="button" - > - <span - class="euiButtonEmpty__content" - > - <svg - aria-hidden="true" - class="euiIcon euiIcon--medium euiIcon-isLoading euiButtonEmpty__icon" - focusable="false" - height="16" - role="img" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - /> - <span - class="euiButtonEmpty__text euiQuickSelectPopover__buttonText" - > - <svg - aria-hidden="true" - class="euiIcon euiIcon--medium euiIcon-isLoading" - focusable="false" - height="16" - role="img" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - /> - </span> - </span> - </button> - </div> - </div> - <div - class="euiFormControlLayout__childrenWrapper" - > - <div - class="euiDatePickerRange euiDatePickerRange--inGroup" - > - <button - class="euiSuperDatePicker__prettyFormat" - data-test-subj="superDatePickerShowDatesButton" - > - Last 15 minutes - <span - class="euiSuperDatePicker__prettyFormatLink" - > - Show dates - </span> - </button> - </div> - </div> - </div> - </div> - <div - class="euiFlexItem euiFlexItem--flexGrowZero" - > - <span - class="euiToolTipAnchor" - > - <button - class="euiButton euiButton--primary euiSuperUpdateButton euiButton--fill" - data-test-subj="superDatePickerApplyTimeButton" - type="button" - > - <span - class="euiButton__content" - > - <svg - aria-hidden="true" - class="euiIcon euiIcon--medium euiIcon-isLoading euiButton__icon" - focusable="false" - height="16" - role="img" - viewBox="0 0 16 16" - width="16" - xmlns="http://www.w3.org/2000/svg" - /> - <span - class="euiButton__text euiSuperUpdateButton__text" - > - Refresh - </span> - </span> - </button> - </span> - </div> - </div> - </div> </div>, <div class="euiSpacer euiSpacer--s" />, ] `; - -exports[`PageHeaderComponent shallow renders expected elements for valid props 1`] = ` -<ContextProvider - value={ - Object { - "history": Object { - "action": "POP", - "block": [Function], - "canGo": [Function], - "createHref": [Function], - "entries": Array [ - Object { - "hash": "", - "key": "TestKeyForTesting", - "pathname": "/", - "search": "", - "state": undefined, - }, - ], - "go": [Function], - "goBack": [Function], - "goForward": [Function], - "index": 0, - "length": 1, - "listen": [Function], - "location": Object { - "hash": "", - "key": "TestKeyForTesting", - "pathname": "/", - "search": "", - "state": undefined, - }, - "push": [Function], - "replace": [Function], - }, - "location": Object { - "hash": "", - "key": "TestKeyForTesting", - "pathname": "/", - "search": "", - "state": undefined, - }, - "match": Object { - "isExact": true, - "params": Object {}, - "path": "/", - "url": "/", - }, - "staticContext": undefined, - } - } -> - <PageHeaderComponent - setBreadcrumbs={[MockFunction]} - /> -</ContextProvider> -`; diff --git a/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx b/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx index 38d074cdb5dba70..c1149834b4f594d 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/__tests__/page_header.test.tsx @@ -6,141 +6,74 @@ import React from 'react'; import { Route } from 'react-router-dom'; -import { PageHeaderComponent } from '../page_header'; -import { mountWithRouter, renderWithRouter, shallowWithRouter } from '../../lib'; -import { MONITOR_ROUTE, OVERVIEW_ROUTE } from '../../../common/constants'; -import { Ping } from '../../../common/graphql/types'; -import { createMemoryHistory } from 'history'; +import { PageHeader, makeBaseBreadcrumb } from '../page_header'; +import { mountWithRouter, renderWithRouter } from '../../lib'; +import { OVERVIEW_ROUTE } from '../../../common/constants'; import { ChromeBreadcrumb } from 'kibana/public'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { UptimeUrlParams, getSupportedUrlParams } from '../../lib/helper'; -describe('PageHeaderComponent', () => { - const monitorStatus: Ping = { - id: 'elastic-co', - tcp: { rtt: { connect: { us: 174982 } } }, - http: { - response: { - body: { - bytes: 2092041, - hash: '5d970606a6be810ae5d37115c4807fdd07ba4c3e407924ee5297e172d2efb3dc', - }, - status_code: 200, - }, - rtt: { - response_header: { us: 340175 }, - write_request: { us: 38 }, - validate: { us: 1797839 }, - content: { us: 1457663 }, - total: { us: 2030012 }, - }, - }, - monitor: { - ip: '2a04:4e42:3::729', - status: 'up', - duration: { us: 2030035 }, - type: 'http', - id: 'elastic-co', - name: 'elastic', - check_group: '2a017afa-4736-11ea-b3d0-acde48001122', - }, - resolve: { ip: '2a04:4e42:3::729', rtt: { us: 2102 } }, - url: { port: 443, full: 'https://www.elastic.co', scheme: 'https', domain: 'www.elastic.co' }, - ecs: { version: '1.4.0' }, - tls: { - certificate_not_valid_after: '2020-07-16T03:15:39.000Z', - rtt: { handshake: { us: 57115 } }, - certificate_not_valid_before: '2019-08-16T01:40:25.000Z', - }, - observer: { - geo: { name: 'US-West', location: '37.422994, -122.083666' }, - }, - timestamp: '2020-02-04T10:07:42.142Z', - }; - - it('shallow renders expected elements for valid props', () => { - const component = shallowWithRouter(<PageHeaderComponent setBreadcrumbs={jest.fn()} />); - expect(component).toMatchSnapshot(); - }); - - it('renders expected elements for valid props', () => { - const component = renderWithRouter(<PageHeaderComponent setBreadcrumbs={jest.fn()} />); - expect(component).toMatchSnapshot(); - }); +describe('PageHeader', () => { + const simpleBreadcrumbs: ChromeBreadcrumb[] = [ + { text: 'TestCrumb1', href: '#testHref1' }, + { text: 'TestCrumb2', href: '#testHref2' }, + ]; - it('renders expected title for valid overview route', () => { + it('shallow renders with breadcrumbs and the date picker', () => { const component = renderWithRouter( - <Route path={OVERVIEW_ROUTE}> - <PageHeaderComponent setBreadcrumbs={jest.fn()} /> - </Route> + <PageHeader + headingText={'TestingHeading'} + breadcrumbs={simpleBreadcrumbs} + datePicker={true} + /> ); - expect(component).toMatchSnapshot(); - - const titleComponent = component.find('.euiTitle'); - expect(titleComponent.text()).toBe('Overview'); + expect(component).toMatchSnapshot('page_header_with_date_picker'); }); - it('renders expected title for valid monitor route', () => { - const history = createMemoryHistory({ initialEntries: ['/monitor/ZWxhc3RpYy1jbw=='] }); - + it('shallow renders with breadcrumbs without the date picker', () => { const component = renderWithRouter( - <Route path={MONITOR_ROUTE}> - <PageHeaderComponent setBreadcrumbs={jest.fn()} monitorStatus={monitorStatus} /> - </Route>, - history + <PageHeader + headingText={'TestingHeading'} + breadcrumbs={simpleBreadcrumbs} + datePicker={false} + /> ); - expect(component).toMatchSnapshot(); - - const titleComponent = component.find('.euiTitle'); - expect(titleComponent.text()).toBe('https://www.elastic.co'); + expect(component).toMatchSnapshot('page_header_no_date_picker'); }); - it('mount expected page title for valid monitor route', () => { - const history = createMemoryHistory({ initialEntries: ['/monitor/ZWxhc3RpYy1jbw=='] }); - - const component = mountWithRouter( - <Route path={MONITOR_ROUTE}> - <PageHeaderComponent setBreadcrumbs={jest.fn()} monitorStatus={monitorStatus} /> - </Route>, - history - ); - expect(component).toMatchSnapshot(); - - const titleComponent = component.find('.euiTitle'); - expect(titleComponent.text()).toBe('https://www.elastic.co'); - expect(document.title).toBe('Uptime | elastic - Kibana'); - }); - - it('mount and set expected breadcrumb for monitor route', () => { - const history = createMemoryHistory({ initialEntries: ['/monitor/ZWxhc3RpYy1jbw=='] }); - let breadcrumbObj: ChromeBreadcrumb[] = []; - const setBreadcrumb = (breadcrumbs: ChromeBreadcrumb[]) => { - breadcrumbObj = breadcrumbs; - }; - + it('sets the given breadcrumbs', () => { + const [getBreadcrumbs, core] = mockCore(); mountWithRouter( - <Route path={MONITOR_ROUTE}> - <PageHeaderComponent setBreadcrumbs={setBreadcrumb} monitorStatus={monitorStatus} /> - </Route>, - history + <KibanaContextProvider services={{ ...core }}> + <Route path={OVERVIEW_ROUTE}> + <PageHeader + headingText={'TestingHeading'} + breadcrumbs={simpleBreadcrumbs} + datePicker={false} + /> + </Route> + </KibanaContextProvider> ); - expect(breadcrumbObj).toStrictEqual([ - { href: '#/?', text: 'Uptime' }, - { text: 'https://www.elastic.co' }, - ]); - }); - - it('mount and set expected breadcrumb for overview route', () => { - let breadcrumbObj: ChromeBreadcrumb[] = []; - const setBreadcrumb = (breadcrumbs: ChromeBreadcrumb[]) => { - breadcrumbObj = breadcrumbs; - }; - - mountWithRouter( - <Route path={OVERVIEW_ROUTE}> - <PageHeaderComponent setBreadcrumbs={setBreadcrumb} monitorStatus={monitorStatus} /> - </Route> + const urlParams: UptimeUrlParams = getSupportedUrlParams({}); + expect(getBreadcrumbs()).toStrictEqual( + [makeBaseBreadcrumb(urlParams)].concat(simpleBreadcrumbs) ); - - expect(breadcrumbObj).toStrictEqual([{ href: '#/', text: 'Uptime' }]); }); }); + +const mockCore: () => [() => ChromeBreadcrumb[], any] = () => { + let breadcrumbObj: ChromeBreadcrumb[] = []; + const get = () => { + return breadcrumbObj; + }; + const core = { + chrome: { + setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => { + breadcrumbObj = newBreadcrumbs; + }, + }, + }; + + return [get, core]; +}; diff --git a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx index 380cc041ae87eaa..8c608f57a959234 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/monitor.tsx @@ -5,19 +5,45 @@ */ import { EuiSpacer } from '@elastic/eui'; -import React, { useContext, useState } from 'react'; +import React, { useContext, useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; +import { ChromeBreadcrumb } from 'kibana/public'; +import { connect, MapDispatchToPropsFunction, MapStateToPropsParam } from 'react-redux'; import { MonitorCharts, PingList } from '../components/functional'; import { UptimeRefreshContext, UptimeThemeContext } from '../contexts'; import { useUptimeTelemetry, useUrlParams, UptimePage } from '../hooks'; import { useTrackPageview } from '../../../../../plugins/observability/public'; import { MonitorStatusDetails } from '../components/connected'; +import { Ping } from '../../common/graphql/types'; +import { AppState } from '../state'; +import { selectSelectedMonitor } from '../state/selectors'; +import { getSelectedMonitor } from '../state/actions'; +import { PageHeader } from './page_header'; -export const MonitorPage = () => { +interface StateProps { + selectedMonitor: Ping | null; +} + +interface DispatchProps { + dispatchGetMonitorStatus: (monitorId: string) => void; +} + +type Props = StateProps & DispatchProps; + +export const MonitorPageComponent: React.FC<Props> = ({ + selectedMonitor, + dispatchGetMonitorStatus, +}: Props) => { // decode 64 base string, it was decoded to make it a valid url, since monitor id can be a url let { monitorId } = useParams(); monitorId = atob(monitorId || ''); + useEffect(() => { + if (monitorId) { + dispatchGetMonitorStatus(monitorId); + } + }, [dispatchGetMonitorStatus, monitorId]); + const [pingListPageCount, setPingListPageCount] = useState<number>(10); const { colors } = useContext(UptimeThemeContext); const { refreshApp } = useContext(UptimeRefreshContext); @@ -39,8 +65,11 @@ export const MonitorPage = () => { useTrackPageview({ app: 'uptime', path: 'monitor' }); useTrackPageview({ app: 'uptime', path: 'monitor', delay: 15000 }); + const nameOrId = selectedMonitor?.monitor?.name || selectedMonitor?.monitor?.id || ''; + const breadcrumbs: ChromeBreadcrumb[] = [{ text: nameOrId }]; return ( <> + <PageHeader headingText={nameOrId} breadcrumbs={breadcrumbs} datePicker={true} /> <EuiSpacer size="s" /> <MonitorStatusDetails monitorId={monitorId} /> <EuiSpacer size="s" /> @@ -65,3 +94,21 @@ export const MonitorPage = () => { </> ); }; + +const mapStateToProps: MapStateToPropsParam<StateProps, {}, AppState> = state => ({ + selectedMonitor: selectSelectedMonitor(state), +}); + +const mapDispatchToProps: MapDispatchToPropsFunction<DispatchProps, {}> = (dispatch, own) => { + return { + dispatchGetMonitorStatus: (monitorId: string) => { + dispatch( + getSelectedMonitor({ + monitorId, + }) + ); + }, + }; +}; + +export const MonitorPage = connect(mapStateToProps, mapDispatchToProps)(MonitorPageComponent); diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index cf3631eda042af4..15e31d5e446291d 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -7,6 +7,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useContext, useEffect } from 'react'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; import { EmptyState, MonitorList, @@ -20,6 +21,7 @@ import { DataPublicPluginSetup, IIndexPattern } from '../../../../../../src/plug import { UptimeThemeContext } from '../contexts'; import { FilterGroup, KueryBar } from '../components/connected'; import { useUpdateKueryString } from '../hooks'; +import { PageHeader } from './page_header'; interface OverviewPageProps { autocomplete: DataPublicPluginSetup['autocomplete']; @@ -71,8 +73,14 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi const linkParameters = stringifyUrlParams(params, true); + const heading = i18n.translate('xpack.uptime.overviewPage.headerText', { + defaultMessage: 'Overview', + description: `The text that will be displayed in the app's heading when the Overview page loads.`, + }); + return ( <> + <PageHeader headingText={heading} breadcrumbs={[]} datePicker={true} /> <EmptyState implementsCustomErrorState={true} variables={{}}> <EuiFlexGroup gutterSize="xs" wrap responsive> <EuiFlexItem grow={1} style={{ flexBasis: 500 }}> diff --git a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx index 5c051c491c6f5b4..b0fb2d0ed7869be 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/page_header.tsx @@ -4,75 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ +import React, { useEffect } from 'react'; +import { ChromeBreadcrumb } from 'kibana/public'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useEffect, useState } from 'react'; -import { useRouteMatch } from 'react-router-dom'; -import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { UptimeDatePicker } from '../components/functional/uptime_date_picker'; -import { getMonitorPageBreadcrumb, getOverviewPageBreadcrumbs } from '../breadcrumbs'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { stringifyUrlParams } from '../lib/helper/stringify_url_params'; -import { getTitle } from '../lib/helper/get_title'; -import { UMUpdateBreadcrumbs } from '../lib/lib'; import { useUrlParams } from '../hooks'; -import { MONITOR_ROUTE } from '../../common/constants'; -import { Ping } from '../../common/graphql/types'; +import { UptimeUrlParams } from '../lib/helper'; interface PageHeaderProps { - monitorStatus?: Ping; - setBreadcrumbs: UMUpdateBreadcrumbs; + headingText: string; + breadcrumbs: ChromeBreadcrumb[]; + datePicker: boolean; } -export const PageHeaderComponent = ({ monitorStatus, setBreadcrumbs }: PageHeaderProps) => { - const monitorPage = useRouteMatch({ - path: MONITOR_ROUTE, - }); - - const [getUrlParams] = useUrlParams(); - const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); - - const headingText = !monitorPage - ? i18n.translate('xpack.uptime.overviewPage.headerText', { - defaultMessage: 'Overview', - description: `The text that will be displayed in the app's heading when the Overview page loads.`, - }) - : monitorStatus?.url?.full; +export const makeBaseBreadcrumb = (params?: UptimeUrlParams): ChromeBreadcrumb => { + let href = '#/'; + if (params) { + const crumbParams: Partial<UptimeUrlParams> = { ...params }; + // We don't want to encode this values because they are often set to Date.now(), the relative + // values in dateRangeStart are better for a URL. + delete crumbParams.absoluteDateRangeStart; + delete crumbParams.absoluteDateRangeEnd; + href += stringifyUrlParams(crumbParams, true); + } + return { + text: i18n.translate('xpack.uptime.breadcrumbs.overviewBreadcrumbText', { + defaultMessage: 'Uptime', + }), + href, + }; +}; - const [headerText, setHeaderText] = useState(headingText); +export const PageHeader = ({ headingText, breadcrumbs, datePicker = true }: PageHeaderProps) => { + const setBreadcrumbs = useKibana().services.chrome?.setBreadcrumbs!; + const params = useUrlParams()[0](); useEffect(() => { - if (monitorPage) { - setHeaderText(monitorStatus?.url?.full ?? ''); - if (monitorStatus?.monitor) { - const { name, id } = monitorStatus.monitor; - document.title = getTitle((name || id) ?? ''); - } - } else { - setHeaderText(headingText); - document.title = getTitle(); - } - }, [monitorStatus, monitorPage, setHeaderText, headingText]); + setBreadcrumbs([makeBaseBreadcrumb(params)].concat(breadcrumbs)); + }, [breadcrumbs, params, setBreadcrumbs]); - useEffect(() => { - if (monitorPage) { - if (headerText) { - setBreadcrumbs(getMonitorPageBreadcrumb(headerText, stringifyUrlParams(params, true))); - } - } else { - setBreadcrumbs(getOverviewPageBreadcrumbs()); - } - }, [headerText, setBreadcrumbs, params, monitorPage]); + const datePickerComponent = datePicker ? ( + <EuiFlexItem grow={false}> + <UptimeDatePicker /> + </EuiFlexItem> + ) : null; return ( <> <EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="s" wrap={true}> <EuiFlexItem> <EuiTitle> - <h1>{headerText}</h1> + <h1>{headingText}</h1> </EuiTitle> </EuiFlexItem> - <EuiFlexItem grow={false}> - <UptimeDatePicker /> - </EuiFlexItem> + {datePickerComponent} </EuiFlexGroup> <EuiSpacer size="s" /> </> diff --git a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx index 66ff5ba7a58ee25..427870797a20642 100644 --- a/x-pack/legacy/plugins/uptime/public/uptime_app.tsx +++ b/x-pack/legacy/plugins/uptime/public/uptime_app.tsx @@ -13,7 +13,7 @@ import { BrowserRouter as Router } from 'react-router-dom'; import { I18nStart, ChromeBreadcrumb, CoreStart } from 'src/core/public'; import { PluginsSetup } from 'ui/new_platform/new_platform'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { UMGraphQLClient, UMUpdateBreadcrumbs, UMUpdateBadge } from './lib/lib'; +import { UMGraphQLClient, UMUpdateBadge } from './lib/lib'; import { UptimeRefreshContextProvider, UptimeSettingsContextProvider, @@ -23,7 +23,6 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker'; import { store } from './state'; import { setBasePath } from './state/actions'; import { PageRouter } from './routes'; -import { PageHeader } from './components/connected/pages/page_header_container'; export interface UptimeAppColors { danger: string; @@ -47,10 +46,10 @@ export interface UptimeAppProps { kibanaBreadcrumbs: ChromeBreadcrumb[]; plugins: PluginsSetup; routerBasename: string; - setBreadcrumbs: UMUpdateBreadcrumbs; setBadge: UMUpdateBadge; renderGlobalHelpControls(): void; commonlyUsedRanges: CommonlyUsedRange[]; + setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; } const Application = (props: UptimeAppProps) => { @@ -64,7 +63,6 @@ const Application = (props: UptimeAppProps) => { plugins, renderGlobalHelpControls, routerBasename, - setBreadcrumbs, setBadge, } = props; @@ -100,7 +98,6 @@ const Application = (props: UptimeAppProps) => { <UptimeThemeContextProvider darkMode={darkMode}> <EuiPage className="app-wrapper-panel " data-test-subj="uptimeApp"> <main> - <PageHeader setBreadcrumbs={setBreadcrumbs} /> <PageRouter autocomplete={plugins.data.autocomplete} /> </main> </EuiPage> From 4226b6f37378f74db4cfa0f2a80c5f47777cf8ec Mon Sep 17 00:00:00 2001 From: Mikhail Shustov <restrry@gmail.com> Date: Tue, 3 Mar 2020 15:46:50 +0100 Subject: [PATCH 19/22] Allow disabling xsrf protection per an endpoint (#58717) * add xsrfRequired flag to a route definition interface * update tests * deprecate server.xsrf.whitelist It meant to be used for IdP endpoints only, which we are going to refactor to disable xsrf requirement per a specific endpoint. * update docs * do not fail on manual KibanaRequest creation * address comments * update tests * address comments * make xsrfRequired available only for destructive methods * update docs * another isSafeMethod usage --- ...na-plugin-server.destructiveroutemethod.md | 13 +++++++++ .../core/server/kibana-plugin-server.md | 2 ++ ...kibana-plugin-server.routeconfigoptions.md | 1 + ...-server.routeconfigoptions.xsrfrequired.md | 15 ++++++++++ .../kibana-plugin-server.routemethod.md | 2 +- .../kibana-plugin-server.saferoutemethod.md | 13 +++++++++ .../deprecation/core_deprecations.test.ts | 13 +++++++++ .../config/deprecation/core_deprecations.ts | 14 +++++++++ src/core/server/http/http_server.mocks.ts | 6 +++- src/core/server/http/http_server.test.ts | 2 ++ src/core/server/http/http_server.ts | 10 +++++-- src/core/server/http/index.ts | 2 ++ .../lifecycle_handlers.test.ts | 11 +++++++ .../server/http/lifecycle_handlers.test.ts | 29 +++++++++++++++++-- src/core/server/http/lifecycle_handlers.ts | 10 +++++-- src/core/server/http/router/index.ts | 4 +++ src/core/server/http/router/request.ts | 14 +++++++-- src/core/server/http/router/route.ts | 27 ++++++++++++++++- src/core/server/index.ts | 2 ++ src/core/server/server.api.md | 9 +++++- 20 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-server.destructiveroutemethod.md create mode 100644 docs/development/core/server/kibana-plugin-server.routeconfigoptions.xsrfrequired.md create mode 100644 docs/development/core/server/kibana-plugin-server.saferoutemethod.md diff --git a/docs/development/core/server/kibana-plugin-server.destructiveroutemethod.md b/docs/development/core/server/kibana-plugin-server.destructiveroutemethod.md new file mode 100644 index 000000000000000..48b1e837f6db956 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.destructiveroutemethod.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [DestructiveRouteMethod](./kibana-plugin-server.destructiveroutemethod.md) + +## DestructiveRouteMethod type + +Set of HTTP methods changing the state of the server. + +<b>Signature:</b> + +```typescript +export declare type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch'; +``` diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index c948c8992079682..0e79385d1ca4d80 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -188,6 +188,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- --> | [ConfigDeprecationLogger](./kibana-plugin-server.configdeprecationlogger.md) | Logger interface used when invoking a [ConfigDeprecation](./kibana-plugin-server.configdeprecation.md) | | [ConfigDeprecationProvider](./kibana-plugin-server.configdeprecationprovider.md) | A provider that should returns a list of [ConfigDeprecation](./kibana-plugin-server.configdeprecation.md)<!-- -->.<!-- -->See [ConfigDeprecationFactory](./kibana-plugin-server.configdeprecationfactory.md) for more usage examples. | | [ConfigPath](./kibana-plugin-server.configpath.md) | | +| [DestructiveRouteMethod](./kibana-plugin-server.destructiveroutemethod.md) | Set of HTTP methods changing the state of the server. | | [ElasticsearchClientConfig](./kibana-plugin-server.elasticsearchclientconfig.md) | | | [GetAuthHeaders](./kibana-plugin-server.getauthheaders.md) | Get headers to authenticate a user against Elasticsearch. | | [GetAuthState](./kibana-plugin-server.getauthstate.md) | Gets authentication state for a request. Returned by <code>auth</code> interceptor. | @@ -232,6 +233,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- --> | [RouteValidationFunction](./kibana-plugin-server.routevalidationfunction.md) | The custom validation function if @<!-- -->kbn/config-schema is not a valid solution for your specific plugin requirements. | | [RouteValidationSpec](./kibana-plugin-server.routevalidationspec.md) | Allowed property validation options: either @<!-- -->kbn/config-schema validations or custom validation functions<!-- -->See [RouteValidationFunction](./kibana-plugin-server.routevalidationfunction.md) for custom validation. | | [RouteValidatorFullConfig](./kibana-plugin-server.routevalidatorfullconfig.md) | Route validations config and options merged into one object | +| [SafeRouteMethod](./kibana-plugin-server.saferoutemethod.md) | Set of HTTP methods not changing the state of the server. | | [SavedObjectAttribute](./kibana-plugin-server.savedobjectattribute.md) | Type definition for a Saved Object attribute value | | [SavedObjectAttributeSingle](./kibana-plugin-server.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-server.savedobjectattribute.md) | | [SavedObjectMigrationFn](./kibana-plugin-server.savedobjectmigrationfn.md) | A migration function for a [saved object type](./kibana-plugin-server.savedobjectstype.md) used to migrate it to a given version | diff --git a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md index 0929e15b6228b70..7fbab90cc2c8a68 100644 --- a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md +++ b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md @@ -19,4 +19,5 @@ export interface RouteConfigOptions<Method extends RouteMethod> | [authRequired](./kibana-plugin-server.routeconfigoptions.authrequired.md) | <code>boolean</code> | A flag shows that authentication for a route: <code>enabled</code> when true <code>disabled</code> when false<!-- -->Enabled by default. | | [body](./kibana-plugin-server.routeconfigoptions.body.md) | <code>Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody</code> | Additional body options [RouteConfigOptionsBody](./kibana-plugin-server.routeconfigoptionsbody.md)<!-- -->. | | [tags](./kibana-plugin-server.routeconfigoptions.tags.md) | <code>readonly string[]</code> | Additional metadata tag strings to attach to the route. | +| [xsrfRequired](./kibana-plugin-server.routeconfigoptions.xsrfrequired.md) | <code>Method extends 'get' ? never : boolean</code> | Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain <code>kbn-xsrf</code> header. - false. Disables xsrf protection.<!-- -->Set to true by default | diff --git a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.xsrfrequired.md b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.xsrfrequired.md new file mode 100644 index 000000000000000..801a0c3dc299b94 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.xsrfrequired.md @@ -0,0 +1,15 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md) > [xsrfRequired](./kibana-plugin-server.routeconfigoptions.xsrfrequired.md) + +## RouteConfigOptions.xsrfRequired property + +Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain `kbn-xsrf` header. - false. Disables xsrf protection. + +Set to true by default + +<b>Signature:</b> + +```typescript +xsrfRequired?: Method extends 'get' ? never : boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.routemethod.md b/docs/development/core/server/kibana-plugin-server.routemethod.md index 939ae94b85691b2..ed0d8e9af4b1943 100644 --- a/docs/development/core/server/kibana-plugin-server.routemethod.md +++ b/docs/development/core/server/kibana-plugin-server.routemethod.md @@ -9,5 +9,5 @@ The set of common HTTP methods supported by Kibana routing. <b>Signature:</b> ```typescript -export declare type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options'; +export declare type RouteMethod = SafeRouteMethod | DestructiveRouteMethod; ``` diff --git a/docs/development/core/server/kibana-plugin-server.saferoutemethod.md b/docs/development/core/server/kibana-plugin-server.saferoutemethod.md new file mode 100644 index 000000000000000..432aa4c6e701477 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.saferoutemethod.md @@ -0,0 +1,13 @@ +<!-- Do not edit this file. It is automatically generated by API Documenter. --> + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SafeRouteMethod](./kibana-plugin-server.saferoutemethod.md) + +## SafeRouteMethod type + +Set of HTTP methods not changing the state of the server. + +<b>Signature:</b> + +```typescript +export declare type SafeRouteMethod = 'get' | 'options'; +``` diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index b40dbdc1b665196..a91e128f62d2dcd 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -81,6 +81,19 @@ describe('core deprecations', () => { }); }); + describe('xsrfDeprecation', () => { + it('logs a warning if server.xsrf.whitelist is set', () => { + const { messages } = applyCoreDeprecations({ + server: { xsrf: { whitelist: ['/path'] } }, + }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "It is not recommended to disable xsrf protections for API endpoints via [server.xsrf.whitelist]. It will be removed in 8.0 release. Instead, supply the \\"kbn-xsrf\\" header.", + ] + `); + }); + }); + describe('rewriteBasePath', () => { it('logs a warning is server.basePath is set and server.rewriteBasePath is not', () => { const { messages } = applyCoreDeprecations({ diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index 4fa51dcd5a08251..d91e55115d0b139 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -38,6 +38,19 @@ const dataPathDeprecation: ConfigDeprecation = (settings, fromPath, log) => { return settings; }; +const xsrfDeprecation: ConfigDeprecation = (settings, fromPath, log) => { + if ( + has(settings, 'server.xsrf.whitelist') && + get<unknown[]>(settings, 'server.xsrf.whitelist').length > 0 + ) { + log( + 'It is not recommended to disable xsrf protections for API endpoints via [server.xsrf.whitelist]. ' + + 'It will be removed in 8.0 release. Instead, supply the "kbn-xsrf" header.' + ); + } + return settings; +}; + const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, log) => { if (has(settings, 'server.basePath') && !has(settings, 'server.rewriteBasePath')) { log( @@ -177,4 +190,5 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({ rewriteBasePathDeprecation, cspRulesDeprecation, mapManifestServiceUrlDeprecation, + xsrfDeprecation, ]; diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index 0a9541393284e34..741c723ca936524 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -29,6 +29,7 @@ import { RouteMethod, KibanaResponseFactory, RouteValidationSpec, + KibanaRouteState, } from './router'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; @@ -43,6 +44,7 @@ interface RequestFixtureOptions<P = any, Q = any, B = any> { method?: RouteMethod; socket?: Socket; routeTags?: string[]; + kibanaRouteState?: KibanaRouteState; routeAuthRequired?: false; validation?: { params?: RouteValidationSpec<P>; @@ -62,6 +64,7 @@ function createKibanaRequestMock<P = any, Q = any, B = any>({ routeTags, routeAuthRequired, validation = {}, + kibanaRouteState = { xsrfRequired: true }, }: RequestFixtureOptions<P, Q, B> = {}) { const queryString = stringify(query, { sort: false }); @@ -80,7 +83,7 @@ function createKibanaRequestMock<P = any, Q = any, B = any>({ search: queryString ? `?${queryString}` : queryString, }, route: { - settings: { tags: routeTags, auth: routeAuthRequired }, + settings: { tags: routeTags, auth: routeAuthRequired, app: kibanaRouteState }, }, raw: { req: { socket }, @@ -109,6 +112,7 @@ function createRawRequestMock(customization: DeepPartial<Request> = {}) { return merge( {}, { + app: { xsrfRequired: true } as any, headers: {}, path: '/', route: { settings: {} }, diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index a9fc80c86d878ea..27db79bb94d2526 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -811,6 +811,7 @@ test('exposes route details of incoming request to a route handler', async () => path: '/', options: { authRequired: true, + xsrfRequired: false, tags: [], }, }); @@ -923,6 +924,7 @@ test('exposes route details of incoming request to a route handler (POST + paylo path: '/', options: { authRequired: true, + xsrfRequired: true, tags: [], body: { parse: true, // hapi populates the default diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 025ab2bf56ac2dc..cffdffab0d0cf77 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -27,7 +27,7 @@ import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_p import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response'; -import { IRouter } from './router'; +import { IRouter, KibanaRouteState, isSafeMethod } from './router'; import { SessionStorageCookieOptions, createCookieSessionStorageFactory, @@ -147,9 +147,14 @@ export class HttpServer { for (const route of router.getRoutes()) { this.log.debug(`registering route handler for [${route.path}]`); // Hapi does not allow payload validation to be specified for 'head' or 'get' requests - const validate = ['head', 'get'].includes(route.method) ? undefined : { payload: true }; + const validate = isSafeMethod(route.method) ? undefined : { payload: true }; const { authRequired = true, tags, body = {} } = route.options; const { accepts: allow, maxBytes, output, parse } = body; + + const kibanaRouteState: KibanaRouteState = { + xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), + }; + this.server.route({ handler: route.handler, method: route.method, @@ -157,6 +162,7 @@ export class HttpServer { options: { // Enforcing the comparison with true because plugins could overwrite the auth strategy by doing `options: { authRequired: authStrategy as any }` auth: authRequired === true ? undefined : false, + app: kibanaRouteState, tags: tags ? Array.from(tags) : undefined, // TODO: This 'validate' section can be removed once the legacy platform is completely removed. // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index d31afe1670e4193..8f4c02680f8a301 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -58,6 +58,8 @@ export { RouteValidationError, RouteValidatorFullConfig, RouteValidationResultFactory, + DestructiveRouteMethod, + SafeRouteMethod, } from './router'; export { BasePathProxyServer } from './base_path_proxy_server'; export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts index f4c5f16870c7ed9..b5364c616f17cf5 100644 --- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -36,6 +36,7 @@ const versionHeader = 'kbn-version'; const xsrfHeader = 'kbn-xsrf'; const nameHeader = 'kbn-name'; const whitelistedTestPath = '/xsrf/test/route/whitelisted'; +const xsrfDisabledTestPath = '/xsrf/test/route/disabled'; const kibanaName = 'my-kibana-name'; const setupDeps = { context: contextServiceMock.createSetupContract(), @@ -188,6 +189,12 @@ describe('core lifecycle handlers', () => { return res.ok({ body: 'ok' }); } ); + ((router as any)[method.toLowerCase()] as RouteRegistrar<any>)<any, any, any>( + { path: xsrfDisabledTestPath, validate: false, options: { xsrfRequired: false } }, + (context, req, res) => { + return res.ok({ body: 'ok' }); + } + ); }); await server.start(); @@ -235,6 +242,10 @@ describe('core lifecycle handlers', () => { it('accepts whitelisted requests without either an xsrf or version header', async () => { await getSupertest(method.toLowerCase(), whitelistedTestPath).expect(200, 'ok'); }); + + it('accepts requests on a route with disabled xsrf protection', async () => { + await getSupertest(method.toLowerCase(), xsrfDisabledTestPath).expect(200, 'ok'); + }); }); }); }); diff --git a/src/core/server/http/lifecycle_handlers.test.ts b/src/core/server/http/lifecycle_handlers.test.ts index 48a6973b741ba0a..a80e432e0d4cb7a 100644 --- a/src/core/server/http/lifecycle_handlers.test.ts +++ b/src/core/server/http/lifecycle_handlers.test.ts @@ -24,7 +24,7 @@ import { } from './lifecycle_handlers'; import { httpServerMock } from './http_server.mocks'; import { HttpConfig } from './http_config'; -import { KibanaRequest, RouteMethod } from './router'; +import { KibanaRequest, RouteMethod, KibanaRouteState } from './router'; const createConfig = (partial: Partial<HttpConfig>): HttpConfig => partial as HttpConfig; @@ -32,12 +32,14 @@ const forgeRequest = ({ headers = {}, path = '/', method = 'get', + kibanaRouteState, }: Partial<{ headers: Record<string, string>; path: string; method: RouteMethod; + kibanaRouteState: KibanaRouteState; }>): KibanaRequest => { - return httpServerMock.createKibanaRequest({ headers, path, method }); + return httpServerMock.createKibanaRequest({ headers, path, method, kibanaRouteState }); }; describe('xsrf post-auth handler', () => { @@ -142,6 +144,29 @@ describe('xsrf post-auth handler', () => { expect(toolkit.next).toHaveBeenCalledTimes(1); expect(result).toEqual('next'); }); + + it('accepts requests if xsrf protection on a route is disabled', () => { + const config = createConfig({ + xsrf: { whitelist: [], disableProtection: false }, + }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ + method: 'post', + headers: {}, + path: '/some-path', + kibanaRouteState: { + xsrfRequired: false, + }, + }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); }); }); diff --git a/src/core/server/http/lifecycle_handlers.ts b/src/core/server/http/lifecycle_handlers.ts index ee877ee031a2bb4..7ef7e8632603910 100644 --- a/src/core/server/http/lifecycle_handlers.ts +++ b/src/core/server/http/lifecycle_handlers.ts @@ -20,6 +20,7 @@ import { OnPostAuthHandler } from './lifecycle/on_post_auth'; import { OnPreResponseHandler } from './lifecycle/on_pre_response'; import { HttpConfig } from './http_config'; +import { isSafeMethod } from './router'; import { Env } from '../config'; import { LifecycleRegistrar } from './http_server'; @@ -31,15 +32,18 @@ export const createXsrfPostAuthHandler = (config: HttpConfig): OnPostAuthHandler const { whitelist, disableProtection } = config.xsrf; return (request, response, toolkit) => { - if (disableProtection || whitelist.includes(request.route.path)) { + if ( + disableProtection || + whitelist.includes(request.route.path) || + request.route.options.xsrfRequired === false + ) { return toolkit.next(); } - const isSafeMethod = request.route.method === 'get' || request.route.method === 'head'; const hasVersionHeader = VERSION_HEADER in request.headers; const hasXsrfHeader = XSRF_HEADER in request.headers; - if (!isSafeMethod && !hasVersionHeader && !hasXsrfHeader) { + if (!isSafeMethod(request.route.method) && !hasVersionHeader && !hasXsrfHeader) { return response.badRequest({ body: `Request must contain a ${XSRF_HEADER} header.` }); } diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index 32663d1513f36bf..d254f391ca5e415 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -24,16 +24,20 @@ export { KibanaRequestEvents, KibanaRequestRoute, KibanaRequestRouteOptions, + KibanaRouteState, isRealRequest, LegacyRequest, ensureRawRequest, } from './request'; export { + DestructiveRouteMethod, + isSafeMethod, RouteMethod, RouteConfig, RouteConfigOptions, RouteContentType, RouteConfigOptionsBody, + SafeRouteMethod, validBodyOutput, } from './route'; export { HapiResponseAdapter } from './response_adapter'; diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index 703571ba53c0a3b..bb2db6367f701f6 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -18,18 +18,24 @@ */ import { Url } from 'url'; -import { Request } from 'hapi'; +import { Request, ApplicationState } from 'hapi'; import { Observable, fromEvent, merge } from 'rxjs'; import { shareReplay, first, takeUntil } from 'rxjs/operators'; import { deepFreeze, RecursiveReadonly } from '../../../utils'; import { Headers } from './headers'; -import { RouteMethod, RouteConfigOptions, validBodyOutput } from './route'; +import { RouteMethod, RouteConfigOptions, validBodyOutput, isSafeMethod } from './route'; import { KibanaSocket, IKibanaSocket } from './socket'; import { RouteValidator, RouteValidatorFullConfig } from './validator'; const requestSymbol = Symbol('request'); +/** + * @internal + */ +export interface KibanaRouteState extends ApplicationState { + xsrfRequired: boolean; +} /** * Route options: If 'GET' or 'OPTIONS' method, body options won't be returned. * @public @@ -184,8 +190,10 @@ export class KibanaRequest< const options = ({ authRequired: request.route.settings.auth !== false, + // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8 + xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true, tags: request.route.settings.tags || [], - body: ['get', 'options'].includes(method) + body: isSafeMethod(method) ? undefined : { parse, diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index 4439a80b1eac71e..d1458ef4ad06327 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -19,11 +19,27 @@ import { RouteValidatorFullConfig } from './validator'; +export function isSafeMethod(method: RouteMethod): method is SafeRouteMethod { + return method === 'get' || method === 'options'; +} + +/** + * Set of HTTP methods changing the state of the server. + * @public + */ +export type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch'; + +/** + * Set of HTTP methods not changing the state of the server. + * @public + */ +export type SafeRouteMethod = 'get' | 'options'; + /** * The set of common HTTP methods supported by Kibana routing. * @public */ -export type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options'; +export type RouteMethod = SafeRouteMethod | DestructiveRouteMethod; /** * The set of valid body.output @@ -108,6 +124,15 @@ export interface RouteConfigOptions<Method extends RouteMethod> { */ authRequired?: boolean; + /** + * Defines xsrf protection requirements for a route: + * - true. Requires an incoming POST/PUT/DELETE request to contain `kbn-xsrf` header. + * - false. Disables xsrf protection. + * + * Set to true by default + */ + xsrfRequired?: Method extends 'get' ? never : boolean; + /** * Additional metadata tag strings to attach to the route. */ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index de6cdb2d7acd78f..0c112e3cfb5b2ac 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -159,6 +159,8 @@ export { SessionStorageCookieOptions, SessionCookieValidationResult, SessionStorageFactory, + DestructiveRouteMethod, + SafeRouteMethod, } from './http'; export { RenderingServiceSetup, IRenderOptions } from './rendering'; export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 445ed16ec7829b0..8c5e84446a0d3c4 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -685,6 +685,9 @@ export interface DeprecationSettings { message: string; } +// @public +export type DestructiveRouteMethod = 'post' | 'put' | 'delete' | 'patch'; + // @public export interface DiscoveredPlugin { readonly configPath: ConfigPath; @@ -1459,6 +1462,7 @@ export interface RouteConfigOptions<Method extends RouteMethod> { authRequired?: boolean; body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; tags?: readonly string[]; + xsrfRequired?: Method extends 'get' ? never : boolean; } // @public @@ -1473,7 +1477,7 @@ export interface RouteConfigOptionsBody { export type RouteContentType = 'application/json' | 'application/*+json' | 'application/octet-stream' | 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/*'; // @public -export type RouteMethod = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options'; +export type RouteMethod = SafeRouteMethod | DestructiveRouteMethod; // @public export type RouteRegistrar<Method extends RouteMethod> = <P, Q, B>(route: RouteConfig<P, Q, B, Method>, handler: RequestHandler<P, Q, B, Method>) => void; @@ -1526,6 +1530,9 @@ export interface RouteValidatorOptions { }; } +// @public +export type SafeRouteMethod = 'get' | 'options'; + // @public (undocumented) export interface SavedObject<T = unknown> { attributes: T; From 0d0973869d07f7d1e88292dce0c38e4302db9d54 Mon Sep 17 00:00:00 2001 From: Spencer <email@spalger.com> Date: Tue, 3 Mar 2020 08:15:32 -0700 Subject: [PATCH 20/22] =?UTF-8?q?[kbn/optimizer]=20add=20test=20to=20verif?= =?UTF-8?q?y=20that=20dynamic=20imports=20keep=20w=E2=80=A6=20(#59065)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [kbn/optimizer] add test to verify that dynamic imports keep working * sort files before adding to cache * oops, committed tmp repo --- .../plugins/foo/public/async_import.ts | 20 +++++++++++++++++++ .../mock_repo/plugins/foo/public/index.ts | 4 ++++ .../basic_optimization.test.ts.snap | 6 ++++-- .../basic_optimization.test.ts | 12 ++++++++--- .../kbn-optimizer/src/worker/run_compilers.ts | 4 ++-- 5 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts new file mode 100644 index 000000000000000..9a51937cbac1e43 --- /dev/null +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/async_import.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function foo() {} diff --git a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts index 9d3871df2473971..1ba0b6968115251 100644 --- a/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts +++ b/packages/kbn-optimizer/src/__fixtures__/mock_repo/plugins/foo/public/index.ts @@ -19,3 +19,7 @@ export * from './lib'; export * from './ext'; + +export async function getFoo() { + return await import('./async_import'); +} diff --git a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap index 1a974d3e81092bf..d52d89eebe2f13b 100644 --- a/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap +++ b/packages/kbn-optimizer/src/integration_tests/__snapshots__/basic_optimization.test.ts.snap @@ -1,5 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`builds expected bundles, saves bundle counts to metadata: 1 async bundle 1`] = `"(window[\\"foo_bundle_jsonpfunction\\"]=window[\\"foo_bundle_jsonpfunction\\"]||[]).push([[1],{3:function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.foo=foo;function foo(){}}}]);"`; + exports[`builds expected bundles, saves bundle counts to metadata: OptimizerConfig 1`] = ` OptimizerConfig { "bundles": Array [ @@ -55,6 +57,6 @@ OptimizerConfig { } `; -exports[`builds expected bundles, saves bundle counts to metadata: bar bundle 1`] = `"var __kbnBundles__=typeof __kbnBundles__===\\"object\\"?__kbnBundles__:{};__kbnBundles__[\\"plugin/bar\\"]=function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"__REPLACE_WITH_PUBLIC_PATH__\\";return __webpack_require__(__webpack_require__.s=4)}([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i<stylesInDom.length;i++){if(stylesInDom[i].identifier===identifier){result=i;break}}return result}function modulesToDom(list,options){var idCountMap={};var identifiers=[];for(var i=0;i<list.length;i++){var item=list[i];var id=options.base?item[0]+options.base:item[0];var count=idCountMap[id]||0;var identifier=\\"\\".concat(id,\\" \\").concat(count);idCountMap[id]=count+1;var index=getIndexByIdentifier(identifier);var obj={css:item[1],media:item[2],sourceMap:item[3]};if(index!==-1){stylesInDom[index].references++;stylesInDom[index].updater(obj)}else{stylesInDom.push({identifier:identifier,updater:addStyle(obj,options),references:1})}identifiers.push(identifier)}return identifiers}function insertStyleElement(options){var style=document.createElement(\\"style\\");var attributes=options.attributes||{};if(typeof attributes.nonce===\\"undefined\\"){var nonce=true?__webpack_require__.nc:undefined;if(nonce){attributes.nonce=nonce}}Object.keys(attributes).forEach((function(key){style.setAttribute(key,attributes[key])}));if(typeof options.insert===\\"function\\"){options.insert(style)}else{var target=getTarget(options.insert||\\"head\\");if(!target){throw new Error(\\"Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.\\")}target.appendChild(style)}return style}function removeStyleElement(style){if(style.parentNode===null){return false}style.parentNode.removeChild(style)}var replaceText=function replaceText(){var textStore=[];return function replace(index,replacement){textStore[index]=replacement;return textStore.filter(Boolean).join(\\"\\\\n\\")}}();function applyToSingletonTag(style,index,remove,obj){var css=remove?\\"\\":obj.media?\\"@media \\".concat(obj.media,\\" {\\").concat(obj.css,\\"}\\"):obj.css;if(style.styleSheet){style.styleSheet.cssText=replaceText(index,css)}else{var cssNode=document.createTextNode(css);var childNodes=style.childNodes;if(childNodes[index]){style.removeChild(childNodes[index])}if(childNodes.length){style.insertBefore(cssNode,childNodes[index])}else{style.appendChild(cssNode)}}}function applyToTag(style,options,obj){var css=obj.css;var media=obj.media;var sourceMap=obj.sourceMap;if(media){style.setAttribute(\\"media\\",media)}else{style.removeAttribute(\\"media\\")}if(sourceMap&&btoa){css+=\\"\\\\n/*# sourceMappingURL=data:application/json;base64,\\".concat(btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap)))),\\" */\\")}if(style.styleSheet){style.styleSheet.cssText=css}else{while(style.firstChild){style.removeChild(style.firstChild)}style.appendChild(document.createTextNode(css))}}var singleton=null;var singletonCounter=0;function addStyle(obj,options){var style;var update;var remove;if(options.singleton){var styleIndex=singletonCounter++;style=singleton||(singleton=insertStyleElement(options));update=applyToSingletonTag.bind(null,style,styleIndex,false);remove=applyToSingletonTag.bind(null,style,styleIndex,true)}else{style=insertStyleElement(options);update=applyToTag.bind(null,style,options);remove=function remove(){removeStyleElement(style)}}update(obj);return function updateStyle(newObj){if(newObj){if(newObj.css===obj.css&&newObj.media===obj.media&&newObj.sourceMap===obj.sourceMap){return}update(obj=newObj)}else{remove()}}}module.exports=function(list,options){options=options||{};if(!options.singleton&&typeof options.singleton!==\\"boolean\\"){options.singleton=isOldIE()}list=list||[];var lastIdentifiers=modulesToDom(list,options);return function update(newList){newList=newList||[];if(Object.prototype.toString.call(newList)!==\\"[object Array]\\"){return}for(var i=0;i<lastIdentifiers.length;i++){var identifier=lastIdentifiers[i];var index=getIndexByIdentifier(identifier);stylesInDom[index].references--}var newLastIdentifiers=modulesToDom(newList,options);for(var _i=0;_i<lastIdentifiers.length;_i++){var _identifier=lastIdentifiers[_i];var _index=getIndexByIdentifier(_identifier);if(stylesInDom[_index].references===0){stylesInDom[_index].updater();stylesInDom.splice(_index,1)}}lastIdentifiers=newLastIdentifiers}}},function(module,exports,__webpack_require__){\\"use strict\\";module.exports=function(useSourceMap){var list=[];list.toString=function toString(){return this.map((function(item){var content=cssWithMappingToString(item,useSourceMap);if(item[2]){return\\"@media \\".concat(item[2],\\" {\\").concat(content,\\"}\\")}return content})).join(\\"\\")};list.i=function(modules,mediaQuery,dedupe){if(typeof modules===\\"string\\"){modules=[[null,modules,\\"\\"]]}var alreadyImportedModules={};if(dedupe){for(var i=0;i<this.length;i++){var id=this[i][0];if(id!=null){alreadyImportedModules[id]=true}}}for(var _i=0;_i<modules.length;_i++){var item=[].concat(modules[_i]);if(dedupe&&alreadyImportedModules[item[0]]){continue}if(mediaQuery){if(!item[2]){item[2]=mediaQuery}else{item[2]=\\"\\".concat(mediaQuery,\\" and \\").concat(item[2])}}list.push(item)}};return list};function cssWithMappingToString(item,useSourceMap){var content=item[1]||\\"\\";var cssMapping=item[3];if(!cssMapping){return content}if(useSourceMap&&typeof btoa===\\"function\\"){var sourceMapping=toComment(cssMapping);var sourceURLs=cssMapping.sources.map((function(source){return\\"/*# sourceURL=\\".concat(cssMapping.sourceRoot||\\"\\").concat(source,\\" */\\")}));return[content].concat(sourceURLs).concat([sourceMapping]).join(\\"\\\\n\\")}return[content].join(\\"\\\\n\\")}function toComment(sourceMap){var base64=btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap))));var data=\\"sourceMappingURL=data:application/json;charset=utf-8;base64,\\".concat(base64);return\\"/*# \\".concat(data,\\" */\\")}},function(module,exports,__webpack_require__){\\"use strict\\";module.exports=function(url,options){if(!options){options={}}url=url&&url.__esModule?url.default:url;if(typeof url!==\\"string\\"){return url}if(/^['\\"].*['\\"]$/.test(url)){url=url.slice(1,-1)}if(options.hash){url+=options.hash}if(/[\\"'() \\\\t\\\\n]/.test(url)||options.needQuotes){return'\\"'.concat(url.replace(/\\"/g,'\\\\\\\\\\"').replace(/\\\\n/g,\\"\\\\\\\\n\\"),'\\"')}return url}},function(module,exports){module.exports=\\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiB2ZXJzaW9uPSIxLjEiPjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIHI9IjI1Ii8+PC9zdmc+\\"},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});var _exportNames={fooLibFn:true};Object.defineProperty(exports,\\"fooLibFn\\",{enumerable:true,get:function get(){return _index.fooLibFn}});__webpack_require__(5);var _index=__webpack_require__(10);var _lib=__webpack_require__(13);Object.keys(_lib).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;if(Object.prototype.hasOwnProperty.call(_exportNames,key))return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _lib[key]}})}))},function(module,exports,__webpack_require__){if(window.__kbnDarkMode__){__webpack_require__(6)}else{__webpack_require__(8)}},function(module,exports,__webpack_require__){var api=__webpack_require__(0);var content=__webpack_require__(7);content=content.__esModule?content.default:content;if(typeof content===\\"string\\"){content=[[module.i,content,\\"\\"]]}var options={};options.insert=\\"head\\";options.singleton=false;var update=api(content,options);var exported=content.locals?content.locals:{};module.exports=exported},function(module,exports,__webpack_require__){var ___CSS_LOADER_API_IMPORT___=__webpack_require__(1);var ___CSS_LOADER_GET_URL_IMPORT___=__webpack_require__(2);var ___CSS_LOADER_URL_IMPORT_0___=__webpack_require__(3);exports=___CSS_LOADER_API_IMPORT___(false);var ___CSS_LOADER_URL_REPLACEMENT_0___=___CSS_LOADER_GET_URL_IMPORT___(___CSS_LOADER_URL_IMPORT_0___);exports.push([module.i,\\"body {\\\\n width: 10;\\\\n background-image: url(\\"+___CSS_LOADER_URL_REPLACEMENT_0___+\\"); }\\\\n\\",\\"\\"]);module.exports=exports},function(module,exports,__webpack_require__){var api=__webpack_require__(0);var content=__webpack_require__(9);content=content.__esModule?content.default:content;if(typeof content===\\"string\\"){content=[[module.i,content,\\"\\"]]}var options={};options.insert=\\"head\\";options.singleton=false;var update=api(content,options);var exported=content.locals?content.locals:{};module.exports=exported},function(module,exports,__webpack_require__){var ___CSS_LOADER_API_IMPORT___=__webpack_require__(1);var ___CSS_LOADER_GET_URL_IMPORT___=__webpack_require__(2);var ___CSS_LOADER_URL_IMPORT_0___=__webpack_require__(3);exports=___CSS_LOADER_API_IMPORT___(false);var ___CSS_LOADER_URL_REPLACEMENT_0___=___CSS_LOADER_GET_URL_IMPORT___(___CSS_LOADER_URL_IMPORT_0___);exports.push([module.i,\\"body {\\\\n width: 10;\\\\n background-image: url(\\"+___CSS_LOADER_URL_REPLACEMENT_0___+\\"); }\\\\n\\",\\"\\"]);module.exports=exports},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});var _lib=__webpack_require__(11);Object.keys(_lib).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _lib[key]}})}));var _ext=__webpack_require__(12);Object.keys(_ext).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _ext[key]}})}))},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.fooLibFn=fooLibFn;function fooLibFn(){return\\"foo\\"}},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.ext=void 0;var ext=\\"TRUE\\";exports.ext=ext},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.barLibFn=barLibFn;function barLibFn(){return\\"bar\\"}}])[\\"plugin\\"];"`; +exports[`builds expected bundles, saves bundle counts to metadata: bar bundle 1`] = `"var __kbnBundles__=typeof __kbnBundles__===\\"object\\"?__kbnBundles__:{};__kbnBundles__[\\"plugin/bar\\"]=function(modules){function webpackJsonpCallback(data){var chunkIds=data[0];var moreModules=data[1];var moduleId,chunkId,i=0,resolves=[];for(;i<chunkIds.length;i++){chunkId=chunkIds[i];if(Object.prototype.hasOwnProperty.call(installedChunks,chunkId)&&installedChunks[chunkId]){resolves.push(installedChunks[chunkId][0])}installedChunks[chunkId]=0}for(moduleId in moreModules){if(Object.prototype.hasOwnProperty.call(moreModules,moduleId)){modules[moduleId]=moreModules[moduleId]}}if(parentJsonpFunction)parentJsonpFunction(data);while(resolves.length){resolves.shift()()}}var installedModules={};var installedChunks={0:0};function jsonpScriptSrc(chunkId){return __webpack_require__.p+\\"\\"+({}[chunkId]||chunkId)+\\".plugin.js\\"}function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.e=function requireEnsure(chunkId){var promises=[];var installedChunkData=installedChunks[chunkId];if(installedChunkData!==0){if(installedChunkData){promises.push(installedChunkData[2])}else{var promise=new Promise((function(resolve,reject){installedChunkData=installedChunks[chunkId]=[resolve,reject]}));promises.push(installedChunkData[2]=promise);var script=document.createElement(\\"script\\");var onScriptComplete;script.charset=\\"utf-8\\";script.timeout=120;if(__webpack_require__.nc){script.setAttribute(\\"nonce\\",__webpack_require__.nc)}script.src=jsonpScriptSrc(chunkId);var error=new Error;onScriptComplete=function(event){script.onerror=script.onload=null;clearTimeout(timeout);var chunk=installedChunks[chunkId];if(chunk!==0){if(chunk){var errorType=event&&(event.type===\\"load\\"?\\"missing\\":event.type);var realSrc=event&&event.target&&event.target.src;error.message=\\"Loading chunk \\"+chunkId+\\" failed.\\\\n(\\"+errorType+\\": \\"+realSrc+\\")\\";error.name=\\"ChunkLoadError\\";error.type=errorType;error.request=realSrc;chunk[1](error)}installedChunks[chunkId]=undefined}};var timeout=setTimeout((function(){onScriptComplete({type:\\"timeout\\",target:script})}),12e4);script.onerror=script.onload=onScriptComplete;document.head.appendChild(script)}}return Promise.all(promises)};__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"__REPLACE_WITH_PUBLIC_PATH__\\";__webpack_require__.oe=function(err){console.error(err);throw err};var jsonpArray=window[\\"bar_bundle_jsonpfunction\\"]=window[\\"bar_bundle_jsonpfunction\\"]||[];var oldJsonpFunction=jsonpArray.push.bind(jsonpArray);jsonpArray.push=webpackJsonpCallback;jsonpArray=jsonpArray.slice();for(var i=0;i<jsonpArray.length;i++)webpackJsonpCallback(jsonpArray[i]);var parentJsonpFunction=oldJsonpFunction;return __webpack_require__(__webpack_require__.s=4)}([function(module,exports,__webpack_require__){\\"use strict\\";var isOldIE=function isOldIE(){var memo;return function memorize(){if(typeof memo===\\"undefined\\"){memo=Boolean(window&&document&&document.all&&!window.atob)}return memo}}();var getTarget=function getTarget(){var memo={};return function memorize(target){if(typeof memo[target]===\\"undefined\\"){var styleTarget=document.querySelector(target);if(window.HTMLIFrameElement&&styleTarget instanceof window.HTMLIFrameElement){try{styleTarget=styleTarget.contentDocument.head}catch(e){styleTarget=null}}memo[target]=styleTarget}return memo[target]}}();var stylesInDom=[];function getIndexByIdentifier(identifier){var result=-1;for(var i=0;i<stylesInDom.length;i++){if(stylesInDom[i].identifier===identifier){result=i;break}}return result}function modulesToDom(list,options){var idCountMap={};var identifiers=[];for(var i=0;i<list.length;i++){var item=list[i];var id=options.base?item[0]+options.base:item[0];var count=idCountMap[id]||0;var identifier=\\"\\".concat(id,\\" \\").concat(count);idCountMap[id]=count+1;var index=getIndexByIdentifier(identifier);var obj={css:item[1],media:item[2],sourceMap:item[3]};if(index!==-1){stylesInDom[index].references++;stylesInDom[index].updater(obj)}else{stylesInDom.push({identifier:identifier,updater:addStyle(obj,options),references:1})}identifiers.push(identifier)}return identifiers}function insertStyleElement(options){var style=document.createElement(\\"style\\");var attributes=options.attributes||{};if(typeof attributes.nonce===\\"undefined\\"){var nonce=true?__webpack_require__.nc:undefined;if(nonce){attributes.nonce=nonce}}Object.keys(attributes).forEach((function(key){style.setAttribute(key,attributes[key])}));if(typeof options.insert===\\"function\\"){options.insert(style)}else{var target=getTarget(options.insert||\\"head\\");if(!target){throw new Error(\\"Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.\\")}target.appendChild(style)}return style}function removeStyleElement(style){if(style.parentNode===null){return false}style.parentNode.removeChild(style)}var replaceText=function replaceText(){var textStore=[];return function replace(index,replacement){textStore[index]=replacement;return textStore.filter(Boolean).join(\\"\\\\n\\")}}();function applyToSingletonTag(style,index,remove,obj){var css=remove?\\"\\":obj.media?\\"@media \\".concat(obj.media,\\" {\\").concat(obj.css,\\"}\\"):obj.css;if(style.styleSheet){style.styleSheet.cssText=replaceText(index,css)}else{var cssNode=document.createTextNode(css);var childNodes=style.childNodes;if(childNodes[index]){style.removeChild(childNodes[index])}if(childNodes.length){style.insertBefore(cssNode,childNodes[index])}else{style.appendChild(cssNode)}}}function applyToTag(style,options,obj){var css=obj.css;var media=obj.media;var sourceMap=obj.sourceMap;if(media){style.setAttribute(\\"media\\",media)}else{style.removeAttribute(\\"media\\")}if(sourceMap&&btoa){css+=\\"\\\\n/*# sourceMappingURL=data:application/json;base64,\\".concat(btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap)))),\\" */\\")}if(style.styleSheet){style.styleSheet.cssText=css}else{while(style.firstChild){style.removeChild(style.firstChild)}style.appendChild(document.createTextNode(css))}}var singleton=null;var singletonCounter=0;function addStyle(obj,options){var style;var update;var remove;if(options.singleton){var styleIndex=singletonCounter++;style=singleton||(singleton=insertStyleElement(options));update=applyToSingletonTag.bind(null,style,styleIndex,false);remove=applyToSingletonTag.bind(null,style,styleIndex,true)}else{style=insertStyleElement(options);update=applyToTag.bind(null,style,options);remove=function remove(){removeStyleElement(style)}}update(obj);return function updateStyle(newObj){if(newObj){if(newObj.css===obj.css&&newObj.media===obj.media&&newObj.sourceMap===obj.sourceMap){return}update(obj=newObj)}else{remove()}}}module.exports=function(list,options){options=options||{};if(!options.singleton&&typeof options.singleton!==\\"boolean\\"){options.singleton=isOldIE()}list=list||[];var lastIdentifiers=modulesToDom(list,options);return function update(newList){newList=newList||[];if(Object.prototype.toString.call(newList)!==\\"[object Array]\\"){return}for(var i=0;i<lastIdentifiers.length;i++){var identifier=lastIdentifiers[i];var index=getIndexByIdentifier(identifier);stylesInDom[index].references--}var newLastIdentifiers=modulesToDom(newList,options);for(var _i=0;_i<lastIdentifiers.length;_i++){var _identifier=lastIdentifiers[_i];var _index=getIndexByIdentifier(_identifier);if(stylesInDom[_index].references===0){stylesInDom[_index].updater();stylesInDom.splice(_index,1)}}lastIdentifiers=newLastIdentifiers}}},function(module,exports,__webpack_require__){\\"use strict\\";module.exports=function(useSourceMap){var list=[];list.toString=function toString(){return this.map((function(item){var content=cssWithMappingToString(item,useSourceMap);if(item[2]){return\\"@media \\".concat(item[2],\\" {\\").concat(content,\\"}\\")}return content})).join(\\"\\")};list.i=function(modules,mediaQuery,dedupe){if(typeof modules===\\"string\\"){modules=[[null,modules,\\"\\"]]}var alreadyImportedModules={};if(dedupe){for(var i=0;i<this.length;i++){var id=this[i][0];if(id!=null){alreadyImportedModules[id]=true}}}for(var _i=0;_i<modules.length;_i++){var item=[].concat(modules[_i]);if(dedupe&&alreadyImportedModules[item[0]]){continue}if(mediaQuery){if(!item[2]){item[2]=mediaQuery}else{item[2]=\\"\\".concat(mediaQuery,\\" and \\").concat(item[2])}}list.push(item)}};return list};function cssWithMappingToString(item,useSourceMap){var content=item[1]||\\"\\";var cssMapping=item[3];if(!cssMapping){return content}if(useSourceMap&&typeof btoa===\\"function\\"){var sourceMapping=toComment(cssMapping);var sourceURLs=cssMapping.sources.map((function(source){return\\"/*# sourceURL=\\".concat(cssMapping.sourceRoot||\\"\\").concat(source,\\" */\\")}));return[content].concat(sourceURLs).concat([sourceMapping]).join(\\"\\\\n\\")}return[content].join(\\"\\\\n\\")}function toComment(sourceMap){var base64=btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap))));var data=\\"sourceMappingURL=data:application/json;charset=utf-8;base64,\\".concat(base64);return\\"/*# \\".concat(data,\\" */\\")}},function(module,exports,__webpack_require__){\\"use strict\\";module.exports=function(url,options){if(!options){options={}}url=url&&url.__esModule?url.default:url;if(typeof url!==\\"string\\"){return url}if(/^['\\"].*['\\"]$/.test(url)){url=url.slice(1,-1)}if(options.hash){url+=options.hash}if(/[\\"'() \\\\t\\\\n]/.test(url)||options.needQuotes){return'\\"'.concat(url.replace(/\\"/g,'\\\\\\\\\\"').replace(/\\\\n/g,\\"\\\\\\\\n\\"),'\\"')}return url}},function(module,exports){module.exports=\\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiB2ZXJzaW9uPSIxLjEiPjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIHI9IjI1Ii8+PC9zdmc+\\"},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});var _exportNames={fooLibFn:true};Object.defineProperty(exports,\\"fooLibFn\\",{enumerable:true,get:function get(){return _index.fooLibFn}});__webpack_require__(5);var _index=__webpack_require__(10);var _lib=__webpack_require__(13);Object.keys(_lib).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;if(Object.prototype.hasOwnProperty.call(_exportNames,key))return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _lib[key]}})}))},function(module,exports,__webpack_require__){if(window.__kbnDarkMode__){__webpack_require__(6)}else{__webpack_require__(8)}},function(module,exports,__webpack_require__){var api=__webpack_require__(0);var content=__webpack_require__(7);content=content.__esModule?content.default:content;if(typeof content===\\"string\\"){content=[[module.i,content,\\"\\"]]}var options={};options.insert=\\"head\\";options.singleton=false;var update=api(content,options);var exported=content.locals?content.locals:{};module.exports=exported},function(module,exports,__webpack_require__){var ___CSS_LOADER_API_IMPORT___=__webpack_require__(1);var ___CSS_LOADER_GET_URL_IMPORT___=__webpack_require__(2);var ___CSS_LOADER_URL_IMPORT_0___=__webpack_require__(3);exports=___CSS_LOADER_API_IMPORT___(false);var ___CSS_LOADER_URL_REPLACEMENT_0___=___CSS_LOADER_GET_URL_IMPORT___(___CSS_LOADER_URL_IMPORT_0___);exports.push([module.i,\\"body {\\\\n width: 10;\\\\n background-image: url(\\"+___CSS_LOADER_URL_REPLACEMENT_0___+\\"); }\\\\n\\",\\"\\"]);module.exports=exports},function(module,exports,__webpack_require__){var api=__webpack_require__(0);var content=__webpack_require__(9);content=content.__esModule?content.default:content;if(typeof content===\\"string\\"){content=[[module.i,content,\\"\\"]]}var options={};options.insert=\\"head\\";options.singleton=false;var update=api(content,options);var exported=content.locals?content.locals:{};module.exports=exported},function(module,exports,__webpack_require__){var ___CSS_LOADER_API_IMPORT___=__webpack_require__(1);var ___CSS_LOADER_GET_URL_IMPORT___=__webpack_require__(2);var ___CSS_LOADER_URL_IMPORT_0___=__webpack_require__(3);exports=___CSS_LOADER_API_IMPORT___(false);var ___CSS_LOADER_URL_REPLACEMENT_0___=___CSS_LOADER_GET_URL_IMPORT___(___CSS_LOADER_URL_IMPORT_0___);exports.push([module.i,\\"body {\\\\n width: 10;\\\\n background-image: url(\\"+___CSS_LOADER_URL_REPLACEMENT_0___+\\"); }\\\\n\\",\\"\\"]);module.exports=exports},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});var _exportNames={getFoo:true};exports.getFoo=getFoo;var _lib=__webpack_require__(11);Object.keys(_lib).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;if(Object.prototype.hasOwnProperty.call(_exportNames,key))return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _lib[key]}})}));var _ext=__webpack_require__(12);Object.keys(_ext).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;if(Object.prototype.hasOwnProperty.call(_exportNames,key))return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _ext[key]}})}));function asyncGeneratorStep(gen,resolve,reject,_next,_throw,key,arg){try{var info=gen[key](arg);var value=info.value}catch(error){reject(error);return}if(info.done){resolve(value)}else{Promise.resolve(value).then(_next,_throw)}}function _asyncToGenerator(fn){return function(){var self=this,args=arguments;return new Promise((function(resolve,reject){var gen=fn.apply(self,args);function _next(value){asyncGeneratorStep(gen,resolve,reject,_next,_throw,\\"next\\",value)}function _throw(err){asyncGeneratorStep(gen,resolve,reject,_next,_throw,\\"throw\\",err)}_next(undefined)}))}}function getFoo(){return _getFoo.apply(this,arguments)}function _getFoo(){_getFoo=_asyncToGenerator(regeneratorRuntime.mark((function _callee(){return regeneratorRuntime.wrap((function _callee$(_context){while(1){switch(_context.prev=_context.next){case 0:_context.next=2;return __webpack_require__.e(1).then(__webpack_require__.t.bind(null,14,7));case 2:return _context.abrupt(\\"return\\",_context.sent);case 3:case\\"end\\":return _context.stop()}}}),_callee)})));return _getFoo.apply(this,arguments)}},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.fooLibFn=fooLibFn;function fooLibFn(){return\\"foo\\"}},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.ext=void 0;var ext=\\"TRUE\\";exports.ext=ext},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.barLibFn=barLibFn;function barLibFn(){return\\"bar\\"}}])[\\"plugin\\"];"`; -exports[`builds expected bundles, saves bundle counts to metadata: foo bundle 1`] = `"var __kbnBundles__=typeof __kbnBundles__===\\"object\\"?__kbnBundles__:{};__kbnBundles__[\\"plugin/foo\\"]=function(modules){var installedModules={};function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"__REPLACE_WITH_PUBLIC_PATH__\\";return __webpack_require__(__webpack_require__.s=0)}([function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});var _lib=__webpack_require__(1);Object.keys(_lib).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _lib[key]}})}));var _ext=__webpack_require__(2);Object.keys(_ext).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _ext[key]}})}))},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.fooLibFn=fooLibFn;function fooLibFn(){return\\"foo\\"}},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.ext=void 0;var ext=\\"TRUE\\";exports.ext=ext}])[\\"plugin\\"];"`; +exports[`builds expected bundles, saves bundle counts to metadata: foo bundle 1`] = `"var __kbnBundles__=typeof __kbnBundles__===\\"object\\"?__kbnBundles__:{};__kbnBundles__[\\"plugin/foo\\"]=function(modules){function webpackJsonpCallback(data){var chunkIds=data[0];var moreModules=data[1];var moduleId,chunkId,i=0,resolves=[];for(;i<chunkIds.length;i++){chunkId=chunkIds[i];if(Object.prototype.hasOwnProperty.call(installedChunks,chunkId)&&installedChunks[chunkId]){resolves.push(installedChunks[chunkId][0])}installedChunks[chunkId]=0}for(moduleId in moreModules){if(Object.prototype.hasOwnProperty.call(moreModules,moduleId)){modules[moduleId]=moreModules[moduleId]}}if(parentJsonpFunction)parentJsonpFunction(data);while(resolves.length){resolves.shift()()}}var installedModules={};var installedChunks={0:0};function jsonpScriptSrc(chunkId){return __webpack_require__.p+\\"\\"+({}[chunkId]||chunkId)+\\".plugin.js\\"}function __webpack_require__(moduleId){if(installedModules[moduleId]){return installedModules[moduleId].exports}var module=installedModules[moduleId]={i:moduleId,l:false,exports:{}};modules[moduleId].call(module.exports,module,module.exports,__webpack_require__);module.l=true;return module.exports}__webpack_require__.e=function requireEnsure(chunkId){var promises=[];var installedChunkData=installedChunks[chunkId];if(installedChunkData!==0){if(installedChunkData){promises.push(installedChunkData[2])}else{var promise=new Promise((function(resolve,reject){installedChunkData=installedChunks[chunkId]=[resolve,reject]}));promises.push(installedChunkData[2]=promise);var script=document.createElement(\\"script\\");var onScriptComplete;script.charset=\\"utf-8\\";script.timeout=120;if(__webpack_require__.nc){script.setAttribute(\\"nonce\\",__webpack_require__.nc)}script.src=jsonpScriptSrc(chunkId);var error=new Error;onScriptComplete=function(event){script.onerror=script.onload=null;clearTimeout(timeout);var chunk=installedChunks[chunkId];if(chunk!==0){if(chunk){var errorType=event&&(event.type===\\"load\\"?\\"missing\\":event.type);var realSrc=event&&event.target&&event.target.src;error.message=\\"Loading chunk \\"+chunkId+\\" failed.\\\\n(\\"+errorType+\\": \\"+realSrc+\\")\\";error.name=\\"ChunkLoadError\\";error.type=errorType;error.request=realSrc;chunk[1](error)}installedChunks[chunkId]=undefined}};var timeout=setTimeout((function(){onScriptComplete({type:\\"timeout\\",target:script})}),12e4);script.onerror=script.onload=onScriptComplete;document.head.appendChild(script)}}return Promise.all(promises)};__webpack_require__.m=modules;__webpack_require__.c=installedModules;__webpack_require__.d=function(exports,name,getter){if(!__webpack_require__.o(exports,name)){Object.defineProperty(exports,name,{enumerable:true,get:getter})}};__webpack_require__.r=function(exports){if(typeof Symbol!==\\"undefined\\"&&Symbol.toStringTag){Object.defineProperty(exports,Symbol.toStringTag,{value:\\"Module\\"})}Object.defineProperty(exports,\\"__esModule\\",{value:true})};__webpack_require__.t=function(value,mode){if(mode&1)value=__webpack_require__(value);if(mode&8)return value;if(mode&4&&typeof value===\\"object\\"&&value&&value.__esModule)return value;var ns=Object.create(null);__webpack_require__.r(ns);Object.defineProperty(ns,\\"default\\",{enumerable:true,value:value});if(mode&2&&typeof value!=\\"string\\")for(var key in value)__webpack_require__.d(ns,key,function(key){return value[key]}.bind(null,key));return ns};__webpack_require__.n=function(module){var getter=module&&module.__esModule?function getDefault(){return module[\\"default\\"]}:function getModuleExports(){return module};__webpack_require__.d(getter,\\"a\\",getter);return getter};__webpack_require__.o=function(object,property){return Object.prototype.hasOwnProperty.call(object,property)};__webpack_require__.p=\\"__REPLACE_WITH_PUBLIC_PATH__\\";__webpack_require__.oe=function(err){console.error(err);throw err};var jsonpArray=window[\\"foo_bundle_jsonpfunction\\"]=window[\\"foo_bundle_jsonpfunction\\"]||[];var oldJsonpFunction=jsonpArray.push.bind(jsonpArray);jsonpArray.push=webpackJsonpCallback;jsonpArray=jsonpArray.slice();for(var i=0;i<jsonpArray.length;i++)webpackJsonpCallback(jsonpArray[i]);var parentJsonpFunction=oldJsonpFunction;return __webpack_require__(__webpack_require__.s=0)}([function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});var _exportNames={getFoo:true};exports.getFoo=getFoo;var _lib=__webpack_require__(1);Object.keys(_lib).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;if(Object.prototype.hasOwnProperty.call(_exportNames,key))return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _lib[key]}})}));var _ext=__webpack_require__(2);Object.keys(_ext).forEach((function(key){if(key===\\"default\\"||key===\\"__esModule\\")return;if(Object.prototype.hasOwnProperty.call(_exportNames,key))return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _ext[key]}})}));function asyncGeneratorStep(gen,resolve,reject,_next,_throw,key,arg){try{var info=gen[key](arg);var value=info.value}catch(error){reject(error);return}if(info.done){resolve(value)}else{Promise.resolve(value).then(_next,_throw)}}function _asyncToGenerator(fn){return function(){var self=this,args=arguments;return new Promise((function(resolve,reject){var gen=fn.apply(self,args);function _next(value){asyncGeneratorStep(gen,resolve,reject,_next,_throw,\\"next\\",value)}function _throw(err){asyncGeneratorStep(gen,resolve,reject,_next,_throw,\\"throw\\",err)}_next(undefined)}))}}function getFoo(){return _getFoo.apply(this,arguments)}function _getFoo(){_getFoo=_asyncToGenerator(regeneratorRuntime.mark((function _callee(){return regeneratorRuntime.wrap((function _callee$(_context){while(1){switch(_context.prev=_context.next){case 0:_context.next=2;return __webpack_require__.e(1).then(__webpack_require__.t.bind(null,3,7));case 2:return _context.abrupt(\\"return\\",_context.sent);case 3:case\\"end\\":return _context.stop()}}}),_callee)})));return _getFoo.apply(this,arguments)}},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.fooLibFn=fooLibFn;function fooLibFn(){return\\"foo\\"}},function(module,exports,__webpack_require__){\\"use strict\\";Object.defineProperty(exports,\\"__esModule\\",{value:true});exports.ext=void 0;var ext=\\"TRUE\\";exports.ext=ext}])[\\"plugin\\"];"`; diff --git a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts index afdf1553216e370..ad743933e117110 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -128,6 +128,10 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/foo.plugin.js'), 'utf8') ).toMatchSnapshot('foo bundle'); + expect( + Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/foo/target/public/1.plugin.js'), 'utf8') + ).toMatchSnapshot('1 async bundle'); + expect( Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, 'plugins/bar/target/public/bar.plugin.js'), 'utf8') ).toMatchSnapshot('bar bundle'); @@ -135,9 +139,10 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { const foo = config.bundles.find(b => b.id === 'foo')!; expect(foo).toBeTruthy(); foo.cache.refresh(); - expect(foo.cache.getModuleCount()).toBe(3); + expect(foo.cache.getModuleCount()).toBe(4); expect(foo.cache.getReferencedFiles()).toMatchInlineSnapshot(` Array [ + <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts, <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, @@ -148,8 +153,8 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { expect(bar).toBeTruthy(); bar.cache.refresh(); expect(bar.cache.getModuleCount()).toBe( - // code + styles + style/css-loader runtime - 14 + // code + styles + style/css-loader runtimes + 15 ); expect(bar.cache.getReferencedFiles()).toMatchInlineSnapshot(` @@ -159,6 +164,7 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/index.ts, <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/legacy/styles.scss, <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/bar/public/lib.ts, + <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/async_import.ts, <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/ext.ts, <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/index.ts, <absolute path>/packages/kbn-optimizer/src/__fixtures__/__tmp__/mock_repo/plugins/foo/public/lib.ts, diff --git a/packages/kbn-optimizer/src/worker/run_compilers.ts b/packages/kbn-optimizer/src/worker/run_compilers.ts index 7a8097fd2b2c799..e87ddc7d0185cfd 100644 --- a/packages/kbn-optimizer/src/worker/run_compilers.ts +++ b/packages/kbn-optimizer/src/worker/run_compilers.ts @@ -127,7 +127,7 @@ const observeCompiler = ( ); } - const files = Array.from(referencedFiles); + const files = Array.from(referencedFiles).sort(ascending(p => p)); const mtimes = new Map( files.map((path): [string, number | undefined] => { try { @@ -146,7 +146,7 @@ const observeCompiler = ( optimizerCacheKey: workerConfig.optimizerCacheKey, cacheKey: bundle.createCacheKey(files, mtimes), moduleCount: normalModules.length, - files: files.sort(ascending(f => f)), + files, }); return compilerMsgs.compilerSuccess({ From 6cacfd0c1505a8107c24d6c222b67b724ab6ff25 Mon Sep 17 00:00:00 2001 From: Spencer <email@spalger.com> Date: Tue, 3 Mar 2020 08:58:50 -0700 Subject: [PATCH 21/22] =?UTF-8?q?[failed-test-report]=20if=20one=20test=20?= =?UTF-8?q?fails=20twice=20don't=20create=20two=E2=80=A6=20(#58778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [failed-test-report] if one test fails twice don't create two issues * fix type check error Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../src/failed_tests_reporter/github_api.ts | 15 +++++++++-- .../report_failure.test.ts | 2 -- .../failed_tests_reporter/report_failure.ts | 4 +-- .../run_failed_tests_reporter_cli.ts | 26 +++++++++++++++---- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/kbn-test/src/failed_tests_reporter/github_api.ts b/packages/kbn-test/src/failed_tests_reporter/github_api.ts index d8a952bee42e591..7da79b5b67e63f4 100644 --- a/packages/kbn-test/src/failed_tests_reporter/github_api.ts +++ b/packages/kbn-test/src/failed_tests_reporter/github_api.ts @@ -33,6 +33,15 @@ export interface GithubIssue { body: string; } +/** + * Minimal GithubIssue type that can be easily replicated by dry-run helpers + */ +export interface GithubIssueMini { + number: GithubIssue['number']; + body: GithubIssue['body']; + html_url: GithubIssue['html_url']; +} + type RequestOptions = AxiosRequestConfig & { safeForDryRun?: boolean; maxAttempts?: number; @@ -162,7 +171,7 @@ export class GithubApi { } async createIssue(title: string, body: string, labels?: string[]) { - const resp = await this.request( + const resp = await this.request<GithubIssueMini>( { method: 'POST', url: Url.resolve(BASE_URL, 'issues'), @@ -173,11 +182,13 @@ export class GithubApi { }, }, { + body, + number: 999, html_url: 'https://dryrun', } ); - return resp.data.html_url; + return resp.data; } private async request<T>( diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts b/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts index ef6ab3c51ab1943..5bbc72fe04e86e0 100644 --- a/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts +++ b/packages/kbn-test/src/failed_tests_reporter/report_failure.test.ts @@ -78,9 +78,7 @@ describe('updateFailureIssue()', () => { 'https://build-url', { html_url: 'https://github.com/issues/1234', - labels: ['some-label'], number: 1234, - title: 'issue title', body: dedent` # existing issue body diff --git a/packages/kbn-test/src/failed_tests_reporter/report_failure.ts b/packages/kbn-test/src/failed_tests_reporter/report_failure.ts index 97e9d517576fc61..1413d054984594f 100644 --- a/packages/kbn-test/src/failed_tests_reporter/report_failure.ts +++ b/packages/kbn-test/src/failed_tests_reporter/report_failure.ts @@ -18,7 +18,7 @@ */ import { TestFailure } from './get_failures'; -import { GithubIssue, GithubApi } from './github_api'; +import { GithubIssueMini, GithubApi } from './github_api'; import { getIssueMetadata, updateIssueMetadata } from './issue_metadata'; export async function createFailureIssue(buildUrl: string, failure: TestFailure, api: GithubApi) { @@ -44,7 +44,7 @@ export async function createFailureIssue(buildUrl: string, failure: TestFailure, return await api.createIssue(title, body, ['failed-test']); } -export async function updateFailureIssue(buildUrl: string, issue: GithubIssue, api: GithubApi) { +export async function updateFailureIssue(buildUrl: string, issue: GithubIssueMini, api: GithubApi) { // Increment failCount const newCount = getIssueMetadata(issue.body, 'test.failCount', 0) + 1; const newBody = updateIssueMetadata(issue.body, { diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index fc52fa6cbf9e7d1..9324f9eb42aa5f6 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -20,8 +20,8 @@ import { REPO_ROOT, run, createFailError, createFlagError } from '@kbn/dev-utils'; import globby from 'globby'; -import { getFailures } from './get_failures'; -import { GithubApi } from './github_api'; +import { getFailures, TestFailure } from './get_failures'; +import { GithubApi, GithubIssueMini } from './github_api'; import { updateFailureIssue, createFailureIssue } from './report_failure'; import { getIssueMetadata } from './issue_metadata'; import { readTestReport } from './test_report'; @@ -73,6 +73,11 @@ export function runFailedTestsReporterCli() { absolute: true, }); + const newlyCreatedIssues: Array<{ + failure: TestFailure; + newIssue: GithubIssueMini; + }> = []; + for (const reportPath of reportPaths) { const report = await readTestReport(reportPath); const messages = Array.from(getReportMessageIter(report)); @@ -94,12 +99,22 @@ export function runFailedTestsReporterCli() { continue; } - const existingIssue = await githubApi.findFailedTestIssue( + let existingIssue: GithubIssueMini | undefined = await githubApi.findFailedTestIssue( i => getIssueMetadata(i.body, 'test.class') === failure.classname && getIssueMetadata(i.body, 'test.name') === failure.name ); + if (!existingIssue) { + const newlyCreated = newlyCreatedIssues.find( + ({ failure: f }) => f.classname === failure.classname && f.name === failure.name + ); + + if (newlyCreated) { + existingIssue = newlyCreated.newIssue; + } + } + if (existingIssue) { const newFailureCount = await updateFailureIssue(buildUrl, existingIssue, githubApi); const url = existingIssue.html_url; @@ -110,11 +125,12 @@ export function runFailedTestsReporterCli() { continue; } - const newIssueUrl = await createFailureIssue(buildUrl, failure, githubApi); + const newIssue = await createFailureIssue(buildUrl, failure, githubApi); pushMessage('Test has not failed recently on tracked branches'); if (updateGithub) { - pushMessage(`Created new issue: ${newIssueUrl}`); + pushMessage(`Created new issue: ${newIssue.html_url}`); } + newlyCreatedIssues.push({ failure, newIssue }); } // mutates report to include messages and writes updated report to disk From 417f7966938b6a4d1a7401a9ed055432618b9c91 Mon Sep 17 00:00:00 2001 From: Justin Juno <50022106+justinjunodev@users.noreply.github.com> Date: Tue, 3 Mar 2020 10:25:59 -0600 Subject: [PATCH 22/22] [Rollups] Fix broken link in Rollup Jobs (#58802) (#58929) --- .../rollup/public/crud_app/services/documentation_links.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js b/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js index ce42b26cc3e862e..bc9cb15e1c5e002 100644 --- a/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js +++ b/x-pack/legacy/plugins/rollup/public/crud_app/services/documentation_links.js @@ -5,11 +5,9 @@ */ let esBase = ''; -let xPackBase = ''; export function setEsBaseAndXPackBase(elasticWebsiteUrl, docLinksVersion) { esBase = `${elasticWebsiteUrl}guide/en/elasticsearch/reference/${docLinksVersion}`; - xPackBase = `${elasticWebsiteUrl}guide/en/x-pack/${docLinksVersion}`; } export const getLogisticalDetailsUrl = () => `${esBase}/rollup-job-config.html#_logistical_details`; @@ -21,4 +19,4 @@ export const getMetricsDetailsUrl = () => `${esBase}/rollup-job-config.html#roll export const getDateHistogramAggregationUrl = () => `${esBase}/search-aggregations-bucket-datehistogram-aggregation.html`; -export const getCronUrl = () => `${xPackBase}/trigger-schedule.html#_cron_expressions`; +export const getCronUrl = () => `${esBase}/trigger-schedule.html#_cron_expressions`;