From 86c64af553bec850358c9d86f588489f2d82ac12 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Mon, 4 May 2020 18:16:48 +0200 Subject: [PATCH 01/59] Bump mapbox-gl dependency from 1.9.0 to 1.10.0 (#64670) --- x-pack/package.json | 4 +- yarn.lock | 93 +++++++++++++++++++++++++++++---------------- 2 files changed, 62 insertions(+), 35 deletions(-) diff --git a/x-pack/package.json b/x-pack/package.json index dcc9b8c61cb960..5d1fbaa5784e0b 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -81,7 +81,7 @@ "@types/json-stable-stringify": "^1.0.32", "@types/jsonwebtoken": "^7.2.8", "@types/lodash": "^3.10.1", - "@types/mapbox-gl": "^1.8.1", + "@types/mapbox-gl": "^1.9.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", "@types/mocha": "^7.0.2", @@ -280,7 +280,7 @@ "lodash.topath": "^4.5.2", "lodash.uniqby": "^4.7.0", "lz-string": "^1.4.4", - "mapbox-gl": "^1.9.0", + "mapbox-gl": "^1.10.0", "mapbox-gl-draw-rectangle-mode": "^1.0.4", "markdown-it": "^10.0.0", "memoize-one": "^5.0.0", diff --git a/yarn.lock b/yarn.lock index 346c4d76d24c99..3c233b76f1a48e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2116,7 +2116,7 @@ resolved "https://registry.yarnpkg.com/@mapbox/geojson-normalize/-/geojson-normalize-0.0.1.tgz#1da1e6b3a7add3ad29909b30f438f60581b7cd80" integrity sha1-HaHms6et060pkJsw9Dj2BYG3zYA= -"@mapbox/geojson-rewind@^0.4.0", "@mapbox/geojson-rewind@^0.4.1": +"@mapbox/geojson-rewind@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@mapbox/geojson-rewind/-/geojson-rewind-0.4.1.tgz#357d79300adb7fec7c1f091512988bca6458f068" integrity sha512-mxo2MEr7izA1uOXcDsw99Kgg6xW3P4H2j4n1lmldsgviIelpssvP+jQDivFKOHrOVJDpTTi5oZJvRcHtU9Uufw== @@ -2126,6 +2126,14 @@ minimist "^1.2.5" sharkdown "^0.1.0" +"@mapbox/geojson-rewind@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@mapbox/geojson-rewind/-/geojson-rewind-0.5.0.tgz#91f0ad56008c120caa19414b644d741249f4f560" + integrity sha512-73l/qJQgj/T/zO1JXVfuVvvKDgikD/7D/rHAD28S9BG1OTstgmftrmqfCx4U+zQAmtsB6HcDA3a7ymdnJZAQgg== + dependencies: + concat-stream "~2.0.0" + minimist "^1.2.5" + "@mapbox/geojson-types@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz#9aecf642cb00eab1080a57c4f949a65b4a5846d6" @@ -2166,20 +2174,20 @@ resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.2.3.tgz#a26ecfb3f0061456d93ee8570dd9587d226ea8bd" integrity sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw== -"@mapbox/mapbox-gl-supported@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.4.0.tgz#36946b22944fe2cfa43cfafd5ef36fdb54a069e4" - integrity sha512-ZD0Io4XK+/vU/4zpANjOtdWfVszAgnaMPsGR6LKsWh4kLIEv9qoobTVmJPPuwuM+ZI2b3BlZ6DYw1XHVmv6YTA== +"@mapbox/mapbox-gl-supported@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz#f60b6a55a5d8e5ee908347d2ce4250b15103dc8e" + integrity sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg== "@mapbox/point-geometry@0.1.0", "@mapbox/point-geometry@^0.1.0", "@mapbox/point-geometry@~0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz#8a83f9335c7860effa2eeeca254332aa0aeed8f2" integrity sha1-ioP5M1x4YO/6Lu7KJUMyqgru2PI= -"@mapbox/tiny-sdf@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@mapbox/tiny-sdf/-/tiny-sdf-1.1.0.tgz#b0b8f5c22005e6ddb838f421ffd257c1f74f9a20" - integrity sha512-dnhyk8X2BkDRWImgHILYAGgo+kuciNYX30CUKj/Qd5eNjh54OWM/mdOS/PWsPeN+3abtN+QDGYM4G220ynVJKA== +"@mapbox/tiny-sdf@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@mapbox/tiny-sdf/-/tiny-sdf-1.1.1.tgz#16a20c470741bfe9191deb336f46e194da4a91ff" + integrity sha512-Ihn1nZcGIswJ5XGbgFAvVumOgWpvIjBX9jiRlIl46uQG9vJOF51ViBYHF95rEZupuyQbEmhLaDPLQlU7fUTsBg== "@mapbox/unitbezier@^0.0.0": version "0.0.0" @@ -4350,10 +4358,10 @@ resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== -"@types/mapbox-gl@^1.8.1": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-1.8.1.tgz#dbc12da1324d5bdb3dbf71b90b77cac17994a1a3" - integrity sha512-DdT/YzpGiYITkj2cUwyqPilPbtZURr1E0vZX0KTyyeNP0t0bxNyKoXo0seAcvUd2MsMgFYwFQh1WRC3x2V0kKQ== +"@types/mapbox-gl@^1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-1.9.1.tgz#78b62f8a1ead78bc525a4c1db84bb71fa0fcc579" + integrity sha512-5LS/fljbGjCPfjtOK5+pz8TT0PL4bBXTnN/PDbPtTQMqQdY/KWTWE4jRPuo0fL5wctd543DCptEUTydn+JK+gA== dependencies: "@types/geojson" "*" @@ -9539,6 +9547,16 @@ concat-stream@~1.5.0: readable-stream "~2.0.0" typedarray "~0.0.5" +concat-stream@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + conf@^1.1.2, conf@^1.3.1: version "1.4.0" resolved "https://registry.yarnpkg.com/conf/-/conf-1.4.0.tgz#1ea66c9d7a9b601674a5bb9d2b8dc3c726625e67" @@ -10355,7 +10373,7 @@ css@2.X, css@^2.0.0, css@^2.2.1, css@^2.2.3, css@^2.2.4: source-map-resolve "^0.5.2" urix "^0.1.0" -csscolorparser@~1.0.2: +csscolorparser@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b" integrity sha1-s085HupNqPPpgjHizNjfnAQfFxs= @@ -14586,10 +14604,10 @@ github-username@^3.0.0: dependencies: gh-got "^5.0.0" -gl-matrix@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.0.0.tgz#888301ac7650e148c3865370e13ec66d08a8381f" - integrity sha512-PD4mVH/C/Zs64kOozeFnKY8ybhgwxXXQYGWdB4h68krAHknWJgk9uKOn6z8YElh5//vs++90pb6csrTIDWnexA== +gl-matrix@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.3.0.tgz#232eef60b1c8b30a28cbbe75b2caf6c48fd6358b" + integrity sha512-COb7LDz+SXaHtl/h4LeaFcNdJdAQSDeVqjiIihSXNrkWObZLhDI4hIkZC11Aeqp7bcE72clzB0BnDXr2SmslRA== glob-all@^3.1.0, glob-all@^3.2.1: version "3.2.1" @@ -20091,33 +20109,33 @@ mapbox-gl-draw-rectangle-mode@^1.0.4: resolved "https://registry.yarnpkg.com/mapbox-gl-draw-rectangle-mode/-/mapbox-gl-draw-rectangle-mode-1.0.4.tgz#42987d68872a5fb5cc5d76d3375ee20cd8bab8f7" integrity sha512-BdF6nwEK2p8n9LQoMPzBO8LhddW1fe+d5vK8HQIei+4VcRnUbKNsEj7Z15FsJxCHzsc2BQKXbESx5GaE8x0imQ== -mapbox-gl@^1.9.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-1.9.0.tgz#53e3e13c99483f362b07a8a763f2d61d580255a5" - integrity sha512-PKpoiB2pPUMrqFfBJpt/oA8On3zcp0adEoDS2YIC2RA6o4EZ9Sq2NPZocb64y7ra3mLUvEb7ps1pLVlPMh6y7w== +mapbox-gl@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-1.10.0.tgz#c33e74d1f328e820e245ff8ed7b5dbbbc4be204f" + integrity sha512-SrJXcR9s5yEsPuW2kKKumA1KqYW9RrL8j7ZcIh6glRQ/x3lwNMfwz/UEJAJcVNgeX+fiwzuBoDIdeGB/vSkZLQ== dependencies: - "@mapbox/geojson-rewind" "^0.4.0" + "@mapbox/geojson-rewind" "^0.5.0" "@mapbox/geojson-types" "^1.0.2" "@mapbox/jsonlint-lines-primitives" "^2.0.2" - "@mapbox/mapbox-gl-supported" "^1.4.0" + "@mapbox/mapbox-gl-supported" "^1.5.0" "@mapbox/point-geometry" "^0.1.0" - "@mapbox/tiny-sdf" "^1.1.0" + "@mapbox/tiny-sdf" "^1.1.1" "@mapbox/unitbezier" "^0.0.0" "@mapbox/vector-tile" "^1.3.1" "@mapbox/whoots-js" "^3.1.0" - csscolorparser "~1.0.2" + csscolorparser "~1.0.3" earcut "^2.2.2" geojson-vt "^3.2.1" - gl-matrix "^3.0.0" + gl-matrix "^3.2.1" grid-index "^1.1.0" - minimist "0.0.8" + minimist "^1.2.5" murmurhash-js "^1.0.0" pbf "^3.2.1" potpack "^1.0.1" quickselect "^2.0.0" rw "^1.3.3" supercluster "^7.0.0" - tinyqueue "^2.0.0" + tinyqueue "^2.0.3" vt-pbf "^3.1.1" mapcap@^1.0.0: @@ -25121,6 +25139,15 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.0.2: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readable-stream@~1.1.0: version "1.1.14" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" @@ -29031,10 +29058,10 @@ tinymath@1.2.1: resolved "https://registry.yarnpkg.com/tinymath/-/tinymath-1.2.1.tgz#f97ed66c588cdbf3c19dfba2ae266ee323db7e47" integrity sha512-8CYutfuHR3ywAJus/3JUhaJogZap1mrUQGzNxdBiQDhP3H0uFdQenvaXvqI8lMehX4RsanRZzxVfjMBREFdQaA== -tinyqueue@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.2.tgz#b4fe66d28a5b503edb99c149f87910059782a0cc" - integrity sha512-1oUV+ZAQaeaf830ui/p5JZpzGBw46qs1pKHcfqIc6/QxYDQuEmcBLIhiT0xAxLnekz+qxQusubIYk4cAS8TB2A== +tinyqueue@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08" + integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA== title-case@^2.1.0: version "2.1.1" From 496f49247419d80ea3d400a35753ba499f2f2bc3 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 4 May 2020 18:39:09 +0200 Subject: [PATCH 02/59] Fix 37422 (#64215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 💡 rename Filter -> ExpressionValueFilter * refactor: 💡 use new filter type in Canvas * fix: 🐛 fix tests after refactor Co-authored-by: Elastic Machine --- .../common/expression_types/specs/filter.ts | 32 ++++++++++--------- src/plugins/expressions/public/index.ts | 2 +- src/plugins/expressions/server/index.ts | 2 +- .../functions/common/exactly.test.js | 2 +- .../functions/common/exactly.ts | 14 +++++--- .../functions/common/saved_lens.test.ts | 15 +++++++-- .../functions/common/saved_lens.ts | 8 ++--- .../functions/common/saved_map.test.ts | 15 +++++++-- .../functions/common/saved_map.ts | 4 +-- .../functions/common/saved_search.test.ts | 15 +++++++-- .../functions/common/saved_search.ts | 4 +-- .../common/saved_visualization.test.ts | 15 +++++++-- .../functions/common/saved_visualization.ts | 4 +-- .../functions/common/timefilter.test.js | 2 +- .../functions/common/timefilter.ts | 11 ++++--- .../functions/server/demodata.test.ts | 7 ++-- .../functions/server/demodata/index.ts | 9 ++++-- .../functions/server/escount.ts | 15 +++++++-- .../functions/server/esdocs.ts | 12 +++++-- .../functions/server/essql.ts | 9 ++++-- .../canvas/common/lib/datatable/query.js | 4 +-- .../canvas/public/functions/filters.ts | 9 ++++-- .../canvas/public/functions/timelion.ts | 7 ++-- .../lib/build_embeddable_filters.test.ts | 12 ++++--- .../public/lib/build_embeddable_filters.ts | 13 ++++---- .../canvas/server/lib/get_es_filter.js | 8 ++--- x-pack/legacy/plugins/canvas/types/state.ts | 4 +-- 27 files changed, 165 insertions(+), 89 deletions(-) diff --git a/src/plugins/expressions/common/expression_types/specs/filter.ts b/src/plugins/expressions/common/expression_types/specs/filter.ts index 01d6b8a603db68..fc1c086e817c9a 100644 --- a/src/plugins/expressions/common/expression_types/specs/filter.ts +++ b/src/plugins/expressions/common/expression_types/specs/filter.ts @@ -17,29 +17,31 @@ * under the License. */ -import { ExpressionTypeDefinition } from '../types'; - -const name = 'filter'; +import { ExpressionTypeDefinition, ExpressionValueBoxed } from '../types'; /** * Represents an object that is a Filter. */ -export interface Filter { - type?: string; - value?: string; - column?: string; - and: Filter[]; - to?: string; - from?: string; - query?: string | null; -} +export type ExpressionValueFilter = ExpressionValueBoxed< + 'filter', + { + filterType?: string; + value?: string; + column?: string; + and: ExpressionValueFilter[]; + to?: string; + from?: string; + query?: string | null; + } +>; -export const filter: ExpressionTypeDefinition = { - name, +export const filter: ExpressionTypeDefinition<'filter', ExpressionValueFilter> = { + name: 'filter', from: { null: () => { return { - type: name, + type: 'filter', + filterType: 'filter', // Any meta data you wish to pass along. meta: {}, // And filters. If you need an "or", create a filter type for it. diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 6814764ee5faa1..ee3fbd7a7b0b00 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -78,7 +78,7 @@ export { ExpressionValueRender, ExpressionValueSearchContext, ExpressionValueUnboxed, - Filter, + ExpressionValueFilter, Font, FontLabel, FontStyle, diff --git a/src/plugins/expressions/server/index.ts b/src/plugins/expressions/server/index.ts index e41135b6939221..61d3838466bef3 100644 --- a/src/plugins/expressions/server/index.ts +++ b/src/plugins/expressions/server/index.ts @@ -69,7 +69,7 @@ export { ExpressionValueRender, ExpressionValueSearchContext, ExpressionValueUnboxed, - Filter, + ExpressionValueFilter, Font, FontLabel, FontStyle, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js index f03bc54757c3c7..2b9bdb59afbdfd 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.test.js @@ -18,7 +18,7 @@ describe('exactly', () => { it("adds an exactly object to 'and'", () => { const result = fn(emptyFilter, { column: 'name', value: 'product2' }); - expect(result.and[0]).toHaveProperty('type', 'exactly'); + expect(result.and[0]).toHaveProperty('filterType', 'exactly'); }); describe('args', () => { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts index 88a24186d60449..5031e8029957bf 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/exactly.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Filter, ExpressionFunctionDefinition } from '../../../types'; +import { ExpressionValueFilter, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -13,7 +13,12 @@ interface Arguments { filterGroup: string; } -export function exactly(): ExpressionFunctionDefinition<'exactly', Filter, Arguments, Filter> { +export function exactly(): ExpressionFunctionDefinition< + 'exactly', + ExpressionValueFilter, + Arguments, + ExpressionValueFilter +> { const { help, args: argHelp } = getFunctionHelp().exactly; return { @@ -43,8 +48,9 @@ export function exactly(): ExpressionFunctionDefinition<'exactly', Filter, Argum fn: (input, args) => { const { value, column } = args; - const filter = { - type: 'exactly', + const filter: ExpressionValueFilter = { + type: 'filter', + filterType: 'exactly', value, column, and: [], diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts index 6b197148e63736..882d1e2ea58b9a 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedLens } from './saved_lens'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts index 2985a68cf855cc..8fc55ddf9cc599 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_lens.ts @@ -8,7 +8,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { TimeRange, Filter as DataFilter } from 'src/plugins/data/public'; import { EmbeddableInput } from 'src/plugins/embeddable/public'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter, TimeRange as TimeRangeArg } from '../../../types'; +import { ExpressionValueFilter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -37,7 +37,7 @@ type Return = EmbeddableExpression; export function savedLens(): ExpressionFunctionDefinition< 'savedLens', - Filter | null, + ExpressionValueFilter | null, Arguments, Return > { @@ -63,8 +63,8 @@ export function savedLens(): ExpressionFunctionDefinition< }, }, type: EmbeddableExpressionType, - fn: (context, args) => { - const filters = context ? context.and : []; + fn: (input, args) => { + const filters = input ? input.and : []; return { type: EmbeddableExpressionType, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts index 63dbae55790a3e..74e41a030de35d 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedMap } from './saved_map'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts index cba19ce7da80f6..df316d0dd182f9 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts @@ -6,7 +6,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; +import { ExpressionValueFilter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -32,7 +32,7 @@ type Output = EmbeddableExpression; export function savedMap(): ExpressionFunctionDefinition< 'savedMap', - Filter | null, + ExpressionValueFilter | null, Arguments, Output > { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts index 67356dae5b3e33..9bd32202b563a0 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedSearch } from './saved_search'; import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts index 87dc7eb5e814cf..277d035ed09587 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_search.ts @@ -13,7 +13,7 @@ import { } from '../../expression_types'; import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter } from '../../../types'; +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -24,7 +24,7 @@ type Output = EmbeddableExpression & { id: SearchInput['id' export function savedSearch(): ExpressionFunctionDefinition< 'savedSearch', - Filter | null, + ExpressionValueFilter | null, Arguments, Output > { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts index 754a113b875544..8327c1433b9af4 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts @@ -6,14 +6,23 @@ jest.mock('ui/new_platform'); import { savedVisualization } from './saved_visualization'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { ExpressionValueFilter } from '../../../types'; -const filterContext = { +const filterContext: ExpressionValueFilter = { + type: 'filter', and: [ - { and: [], value: 'filter-value', column: 'filter-column', type: 'exactly' }, { + type: 'filter', + and: [], + value: 'filter-value', + column: 'filter-column', + filterType: 'exactly', + }, + { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts index d98fea2ec1be89..94c7a1c8a9eeab 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts @@ -12,7 +12,7 @@ import { EmbeddableExpression, } from '../../expression_types'; import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; +import { ExpressionValueFilter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -31,7 +31,7 @@ const defaultTimeRange = { export function savedVisualization(): ExpressionFunctionDefinition< 'savedVisualization', - Filter | null, + ExpressionValueFilter | null, Arguments, Output > { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js index aeab0d50c31a71..834b9d195856cc 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.test.js @@ -44,7 +44,7 @@ describe('timefilter', () => { from: fromDate, to: toDate, }).and[0] - ).toHaveProperty('type', 'time'); + ).toHaveProperty('filterType', 'time'); }); describe('args', () => { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts index 249faf6141b46c..ff7b56d8194dfb 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/timefilter.ts @@ -5,7 +5,7 @@ */ import dateMath from '@elastic/datemath'; -import { Filter, ExpressionFunctionDefinition } from '../../../types'; +import { ExpressionValueFilter, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; interface Arguments { @@ -17,9 +17,9 @@ interface Arguments { export function timefilter(): ExpressionFunctionDefinition< 'timefilter', - Filter, + ExpressionValueFilter, Arguments, - Filter + ExpressionValueFilter > { const { help, args: argHelp } = getFunctionHelp().timefilter; const errors = getFunctionErrors().timefilter; @@ -58,8 +58,9 @@ export function timefilter(): ExpressionFunctionDefinition< } const { from, to, column } = args; - const filter: Filter = { - type: 'time', + const filter: ExpressionValueFilter = { + type: 'filter', + filterType: 'time', column, and: [], }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts index 94b2d5228665bb..2b517664793a7f 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata.test.ts @@ -5,12 +5,11 @@ */ import { demodata } from './demodata'; +import { ExpressionValueFilter } from '../../../types'; -const nullFilter = { +const nullFilter: ExpressionValueFilter = { type: 'filter', - meta: {}, - size: null, - sort: [], + filterType: 'filter', and: [], }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts index 5cebae5bb669f7..843e2bda47e125 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts @@ -10,14 +10,19 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; import { queryDatatable } from '../../../../common/lib/datatable/query'; import { DemoRows } from './demo_rows_types'; import { getDemoRows } from './get_demo_rows'; -import { Filter, Datatable, DatatableColumn, DatatableRow } from '../../../../types'; +import { ExpressionValueFilter, Datatable, DatatableColumn, DatatableRow } from '../../../../types'; import { getFunctionHelp } from '../../../../i18n'; interface Arguments { type: string; } -export function demodata(): ExpressionFunctionDefinition<'demodata', Filter, Arguments, Datatable> { +export function demodata(): ExpressionFunctionDefinition< + 'demodata', + ExpressionValueFilter, + Arguments, + Datatable +> { const { help, args: argHelp } = getFunctionHelp().demodata; return { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts index ffb8bb4f3e2a79..3f5d0610b4c724 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/escount.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ExpressionFunctionDefinition, Filter } from 'src/plugins/expressions/common'; +import { + ExpressionFunctionDefinition, + ExpressionValueFilter, +} from 'src/plugins/expressions/common'; // @ts-ignore untyped local import { buildESRequest } from '../../../server/lib/build_es_request'; import { getFunctionHelp } from '../../../i18n'; @@ -14,7 +17,12 @@ interface Arguments { query: string; } -export function escount(): ExpressionFunctionDefinition<'escount', Filter, Arguments, any> { +export function escount(): ExpressionFunctionDefinition< + 'escount', + ExpressionValueFilter, + Arguments, + any +> { const { help, args: argHelp } = getFunctionHelp().escount; return { @@ -40,7 +48,8 @@ export function escount(): ExpressionFunctionDefinition<'escount', Filter, Argum fn: (input, args, handlers) => { input.and = input.and.concat([ { - type: 'luceneQueryString', + type: 'filter', + filterType: 'luceneQueryString', query: args.query, and: [], }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts index 5bff06bb3933ba..d60297ee2da3f1 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/esdocs.ts @@ -8,7 +8,7 @@ import squel from 'squel'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions'; // @ts-ignore untyped local import { queryEsSQL } from '../../../server/lib/query_es_sql'; -import { Filter } from '../../../types'; +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -20,7 +20,12 @@ interface Arguments { count: number; } -export function esdocs(): ExpressionFunctionDefinition<'esdocs', Filter, Arguments, any> { +export function esdocs(): ExpressionFunctionDefinition< + 'esdocs', + ExpressionValueFilter, + Arguments, + any +> { const { help, args: argHelp } = getFunctionHelp().esdocs; return { @@ -67,7 +72,8 @@ export function esdocs(): ExpressionFunctionDefinition<'esdocs', Filter, Argumen input.and = input.and.concat([ { - type: 'luceneQueryString', + type: 'filter', + filterType: 'luceneQueryString', query: args.query, and: [], }, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts index cdb6b5af820155..b972f5a3bd4a6c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/server/essql.ts @@ -7,7 +7,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; // @ts-ignore untyped local import { queryEsSQL } from '../../../server/lib/query_es_sql'; -import { Filter } from '../../../types'; +import { ExpressionValueFilter } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { @@ -16,7 +16,12 @@ interface Arguments { timezone: string; } -export function essql(): ExpressionFunctionDefinition<'essql', Filter, Arguments, any> { +export function essql(): ExpressionFunctionDefinition< + 'essql', + ExpressionValueFilter, + Arguments, + any +> { const { help, args: argHelp } = getFunctionHelp().essql; return { diff --git a/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js b/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js index f61e2b64346979..63945ce7690f9a 100644 --- a/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js +++ b/x-pack/legacy/plugins/canvas/common/lib/datatable/query.js @@ -15,14 +15,14 @@ export function queryDatatable(datatable, query) { if (query.and) { query.and.forEach(filter => { // handle exact matches - if (filter.type === 'exactly') { + if (filter.filterType === 'exactly') { datatable.rows = datatable.rows.filter(row => { return row[filter.column] === filter.value; }); } // handle time filters - if (filter.type === 'time') { + if (filter.filterType === 'time') { const columnNames = datatable.columns.map(col => col.name); // remove row if no column match diff --git a/x-pack/legacy/plugins/canvas/public/functions/filters.ts b/x-pack/legacy/plugins/canvas/public/functions/filters.ts index 2a3bc481d7dae3..16d0bb0fff7084 100644 --- a/x-pack/legacy/plugins/canvas/public/functions/filters.ts +++ b/x-pack/legacy/plugins/canvas/public/functions/filters.ts @@ -11,7 +11,7 @@ import { interpretAst } from '../lib/run_interpreter'; // @ts-ignore untyped local import { getState } from '../state/store'; import { getGlobalFilters } from '../state/selectors/workpad'; -import { Filter } from '../../types'; +import { ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from '.'; @@ -41,7 +41,12 @@ function getFiltersByGroup(allFilters: string[], groups?: string[], ungrouped = }); } -type FiltersFunction = ExpressionFunctionDefinition<'filters', null, Arguments, Filter>; +type FiltersFunction = ExpressionFunctionDefinition< + 'filters', + null, + Arguments, + ExpressionValueFilter +>; export function filtersFunctionFactory(initialize: InitializeArguments): () => FiltersFunction { return function filters(): FiltersFunction { diff --git a/x-pack/legacy/plugins/canvas/public/functions/timelion.ts b/x-pack/legacy/plugins/canvas/public/functions/timelion.ts index e59d798108945a..d07b3bf6d1d1c0 100644 --- a/x-pack/legacy/plugins/canvas/public/functions/timelion.ts +++ b/x-pack/legacy/plugins/canvas/public/functions/timelion.ts @@ -11,7 +11,7 @@ import { ExpressionFunctionDefinition, DatatableRow } from 'src/plugins/expressi import { fetch } from '../../common/lib/fetch'; // @ts-ignore untyped local import { buildBoolArray } from '../../server/lib/build_bool_array'; -import { Datatable, Filter } from '../../types'; +import { Datatable, ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from './'; @@ -49,7 +49,7 @@ function parseDateMath( type TimelionFunction = ExpressionFunctionDefinition< 'timelion', - Filter, + ExpressionValueFilter, Arguments, Promise >; @@ -94,11 +94,10 @@ export function timelionFunctionFactory(initialize: InitializeArguments): () => fn: (input, args): Promise => { // Timelion requires a time range. Use the time range from the timefilter element in the // workpad, if it exists. Otherwise fall back on the function args. - const timeFilter = input.and.find(and => and.type === 'time'); + const timeFilter = input.and.find(and => and.filterType === 'time'); const range = timeFilter ? { min: timeFilter.from, max: timeFilter.to } : parseDateMath({ from: args.from, to: args.to }, args.timezone, initialize.timefilter); - const body = { extended: { es: { diff --git a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts index b422a9451293ff..77be181d473783 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.test.ts @@ -5,19 +5,21 @@ */ import { buildEmbeddableFilters } from './build_embeddable_filters'; -import { Filter } from '../../types'; +import { ExpressionValueFilter } from '../../types'; -const columnFilter: Filter = { +const columnFilter: ExpressionValueFilter = { + type: 'filter', and: [], value: 'filter-value', column: 'filter-column', - type: 'exactly', + filterType: 'exactly', }; -const timeFilter: Filter = { +const timeFilter: ExpressionValueFilter = { + type: 'filter', and: [], column: 'time-column', - type: 'time', + filterType: 'time', from: '2019-06-04T04:00:00.000Z', to: '2019-06-05T04:00:00.000Z', }; diff --git a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts index 1a5d2119a94b67..aa915d0d3d02a4 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/build_embeddable_filters.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Filter } from '../../types'; +import { ExpressionValueFilter } from '../../types'; // @ts-ignore Untyped Local import { buildBoolArray } from './build_bool_array'; import { @@ -20,9 +20,9 @@ export interface EmbeddableFilterInput { const TimeFilterType = 'time'; -function getTimeRangeFromFilters(filters: Filter[]): TimeRange | undefined { +function getTimeRangeFromFilters(filters: ExpressionValueFilter[]): TimeRange | undefined { const timeFilter = filters.find( - filter => filter.type !== undefined && filter.type === TimeFilterType + filter => filter.filterType !== undefined && filter.filterType === TimeFilterType ); return timeFilter !== undefined && timeFilter.from !== undefined && timeFilter.to !== undefined @@ -33,11 +33,12 @@ function getTimeRangeFromFilters(filters: Filter[]): TimeRange | undefined { : undefined; } -export function getQueryFilters(filters: Filter[]): DataFilter[] { - return buildBoolArray(filters).map(esFilters.buildQueryFilter); +export function getQueryFilters(filters: ExpressionValueFilter[]): DataFilter[] { + const dataFilters = filters.map(filter => ({ ...filter, type: filter.filterType })); + return buildBoolArray(dataFilters).map(esFilters.buildQueryFilter); } -export function buildEmbeddableFilters(filters: Filter[]): EmbeddableFilterInput { +export function buildEmbeddableFilters(filters: ExpressionValueFilter[]): EmbeddableFilterInput { return { timeRange: getTimeRangeFromFilters(filters), filters: getQueryFilters(filters), diff --git a/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js b/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js index e8a4d704118e8b..7c025ed8dee9b4 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js +++ b/x-pack/legacy/plugins/canvas/server/lib/get_es_filter.js @@ -14,13 +14,13 @@ import * as filters from './filters'; export function getESFilter(filter) { - if (!filters[filter.type]) { - throw new Error(`Unknown filter type: ${filter.type}`); + if (!filters[filter.filterType]) { + throw new Error(`Unknown filter type: ${filter.filterType}`); } try { - return filters[filter.type](filter); + return filters[filter.filterType](filter); } catch (e) { - throw new Error(`Could not create elasticsearch filter from ${filter.type}`); + throw new Error(`Could not create elasticsearch filter from ${filter.filterType}`); } } diff --git a/x-pack/legacy/plugins/canvas/types/state.ts b/x-pack/legacy/plugins/canvas/types/state.ts index 13c8f7a9176abf..e9b580f81e668b 100644 --- a/x-pack/legacy/plugins/canvas/types/state.ts +++ b/x-pack/legacy/plugins/canvas/types/state.ts @@ -6,7 +6,7 @@ import { Datatable, - Filter, + ExpressionValueFilter, ExpressionImage, ExpressionFunction, KibanaContext, @@ -46,7 +46,7 @@ interface ElementStatsType { type ExpressionType = | Datatable - | Filter + | ExpressionValueFilter | ExpressionImage | KibanaContext | KibanaDatatable From 306a5fe55ebbf05d095e5f0504e495cef2032049 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Mon, 4 May 2020 10:53:06 -0600 Subject: [PATCH 03/59] Use brotli compression for some KP assets (#64367) --- package.json | 2 + packages/kbn-optimizer/package.json | 2 + .../basic_optimization.test.ts | 39 +++++++--- .../src/worker/webpack.config.ts | 11 +++ packages/kbn-ui-shared-deps/package.json | 1 + packages/kbn-ui-shared-deps/webpack.config.js | 11 +++ renovate.json5 | 2 + src/dev/renovate/package_groups.ts | 12 ++- .../bundles_route/dynamic_asset_response.ts | 45 +++++++++++- test/functional/apps/bundles/index.js | 73 +++++++++++++++++++ test/functional/config.js | 3 +- test/functional/services/index.ts | 2 + test/functional/services/supertest.ts | 29 ++++++++ typings/accept.d.ts | 23 ++++++ yarn.lock | 42 ++++++++--- 15 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 test/functional/apps/bundles/index.js create mode 100644 test/functional/services/supertest.ts create mode 100644 typings/accept.d.ts diff --git a/package.json b/package.json index 1e3ddc976aa67c..e711235e16ea53 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "@types/tar": "^4.0.3", "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", + "accept": "3.0.2", "angular": "^1.7.9", "angular-aria": "^1.7.9", "angular-elastic": "^2.5.1", @@ -310,6 +311,7 @@ "@percy/agent": "^0.26.0", "@testing-library/react": "^9.3.2", "@testing-library/react-hooks": "^3.2.1", + "@types/accept": "3.1.1", "@types/angular": "^1.6.56", "@types/angular-mocks": "^1.7.0", "@types/babel__core": "^7.1.2", diff --git a/packages/kbn-optimizer/package.json b/packages/kbn-optimizer/package.json index b3e5a8c518682e..b7c9a63897bf9f 100644 --- a/packages/kbn-optimizer/package.json +++ b/packages/kbn-optimizer/package.json @@ -14,6 +14,7 @@ "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", + "@types/compression-webpack-plugin": "^2.0.1", "@types/estree": "^0.0.44", "@types/loader-utils": "^1.1.3", "@types/watchpack": "^1.1.5", @@ -23,6 +24,7 @@ "autoprefixer": "^9.7.4", "babel-loader": "^8.0.6", "clean-webpack-plugin": "^3.0.0", + "compression-webpack-plugin": "^3.1.0", "cpy": "^8.0.0", "css-loader": "^3.4.2", "del": "^5.1.0", 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 ad743933e11711..248b0b7cf4c971 100644 --- a/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts +++ b/packages/kbn-optimizer/src/integration_tests/basic_optimization.test.ts @@ -19,6 +19,7 @@ import Path from 'path'; import Fs from 'fs'; +import Zlib from 'zlib'; import { inspect } from 'util'; import cpy from 'cpy'; @@ -124,17 +125,12 @@ it('builds expected bundles, saves bundle counts to metadata', async () => { ); assert('produce zero unexpected states', otherStates.length === 0, otherStates); - expect( - 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'); + expectFileMatchesSnapshotWithCompression('plugins/foo/target/public/foo.plugin.js', 'foo bundle'); + expectFileMatchesSnapshotWithCompression( + 'plugins/foo/target/public/1.plugin.js', + '1 async bundle' + ); + expectFileMatchesSnapshotWithCompression('plugins/bar/target/public/bar.plugin.js', 'bar bundle'); const foo = config.bundles.find(b => b.id === 'foo')!; expect(foo).toBeTruthy(); @@ -203,3 +199,24 @@ it('uses cache on second run and exist cleanly', async () => { ] `); }); + +/** + * Verifies that the file matches the expected output and has matching compressed variants. + */ +const expectFileMatchesSnapshotWithCompression = (filePath: string, snapshotLabel: string) => { + const raw = Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, filePath), 'utf8'); + expect(raw).toMatchSnapshot(snapshotLabel); + + // Verify the brotli variant matches + expect( + // @ts-ignore @types/node is missing the brotli functions + Zlib.brotliDecompressSync( + Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, `${filePath}.br`)) + ).toString() + ).toEqual(raw); + + // Verify the gzip variant matches + expect( + Zlib.gunzipSync(Fs.readFileSync(Path.resolve(MOCK_REPO_DIR, `${filePath}.gz`))).toString() + ).toEqual(raw); +}; diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index cc3fa8c2720ded..95e826e7620aa6 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -28,6 +28,7 @@ import TerserPlugin from 'terser-webpack-plugin'; import webpackMerge from 'webpack-merge'; // @ts-ignore import { CleanWebpackPlugin } from 'clean-webpack-plugin'; +import CompressionPlugin from 'compression-webpack-plugin'; import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { Bundle, WorkerConfig, parseDirPath, DisallowedSyntaxPlugin } from '../common'; @@ -319,6 +320,16 @@ export function getWebpackConfig(bundle: Bundle, worker: WorkerConfig) { IS_KIBANA_DISTRIBUTABLE: `"true"`, }, }), + new CompressionPlugin({ + algorithm: 'brotliCompress', + filename: '[path].br', + test: /\.(js|css)$/, + }), + new CompressionPlugin({ + algorithm: 'gzip', + filename: '[path].gz', + test: /\.(js|css)$/, + }), ], optimization: { diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index a60e2b0449d958..ec61e8519c960e 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -14,6 +14,7 @@ "@kbn/i18n": "1.0.0", "abortcontroller-polyfill": "^1.4.0", "angular": "^1.7.9", + "compression-webpack-plugin": "^3.1.0", "core-js": "^3.6.4", "custom-event-polyfill": "^0.3.0", "elasticsearch-browser": "^16.7.0", diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index bf63c577658595..52e7bb620b50b4 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -20,6 +20,7 @@ const Path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const CompressionPlugin = require('compression-webpack-plugin'); const { REPO_ROOT } = require('@kbn/dev-utils'); const webpack = require('webpack'); @@ -117,5 +118,15 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ new webpack.DefinePlugin({ 'process.env.NODE_ENV': dev ? '"development"' : '"production"', }), + new CompressionPlugin({ + algorithm: 'brotliCompress', + filename: '[path].br', + test: /\.(js|css)$/, + }), + new CompressionPlugin({ + algorithm: 'gzip', + filename: '[path].gz', + test: /\.(js|css)$/, + }), ], }); diff --git a/renovate.json5 b/renovate.json5 index c0ddcaf4f23c8f..61b2485ecf44b0 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -398,6 +398,8 @@ '@types/good-squeeze', 'inert', '@types/inert', + 'accept', + '@types/accept', ], }, { diff --git a/src/dev/renovate/package_groups.ts b/src/dev/renovate/package_groups.ts index 1bc65fd149f471..9f5aa8556ac214 100644 --- a/src/dev/renovate/package_groups.ts +++ b/src/dev/renovate/package_groups.ts @@ -159,7 +159,17 @@ export const RENOVATE_PACKAGE_GROUPS: PackageGroup[] = [ { name: 'hapi', packageWords: ['hapi'], - packageNames: ['hapi', 'joi', 'boom', 'hoek', 'h2o2', '@elastic/good', 'good-squeeze', 'inert'], + packageNames: [ + 'hapi', + 'joi', + 'boom', + 'hoek', + 'h2o2', + '@elastic/good', + 'good-squeeze', + 'inert', + 'accept', + ], }, { diff --git a/src/optimize/bundles_route/dynamic_asset_response.ts b/src/optimize/bundles_route/dynamic_asset_response.ts index a020c6935eeecf..2f5395341abb10 100644 --- a/src/optimize/bundles_route/dynamic_asset_response.ts +++ b/src/optimize/bundles_route/dynamic_asset_response.ts @@ -21,6 +21,7 @@ import Fs from 'fs'; import { resolve } from 'path'; import { promisify } from 'util'; +import Accept from 'accept'; import Boom from 'boom'; import Hapi from 'hapi'; @@ -37,6 +38,41 @@ const asyncOpen = promisify(Fs.open); const asyncClose = promisify(Fs.close); const asyncFstat = promisify(Fs.fstat); +async function tryToOpenFile(filePath: string) { + try { + return await asyncOpen(filePath, 'r'); + } catch (e) { + if (e.code === 'ENOENT') { + return undefined; + } else { + throw e; + } + } +} + +async function selectCompressedFile(acceptEncodingHeader: string | undefined, path: string) { + let fd: number | undefined; + let fileEncoding: 'gzip' | 'br' | undefined; + + const supportedEncodings = Accept.encodings(acceptEncodingHeader, ['br', 'gzip']); + + if (supportedEncodings[0] === 'br') { + fileEncoding = 'br'; + fd = await tryToOpenFile(`${path}.br`); + } + if (!fd && supportedEncodings.includes('gzip')) { + fileEncoding = 'gzip'; + fd = await tryToOpenFile(`${path}.gz`); + } + if (!fd) { + fileEncoding = undefined; + // Use raw open to trigger exception if it does not exist + fd = await asyncOpen(path, 'r'); + } + + return { fd, fileEncoding }; +} + /** * Create a Hapi response for the requested path. This is designed * to replicate a subset of the features provided by Hapi's Inert @@ -74,6 +110,7 @@ export async function createDynamicAssetResponse({ isDist: boolean; }) { let fd: number | undefined; + let fileEncoding: 'gzip' | 'br' | undefined; try { const path = resolve(bundlesPath, request.params.path); @@ -86,7 +123,7 @@ export async function createDynamicAssetResponse({ // we use and manage a file descriptor mostly because // that's what Inert does, and since we are accessing // the file 2 or 3 times per request it seems logical - fd = await asyncOpen(path, 'r'); + ({ fd, fileEncoding } = await selectCompressedFile(request.headers['accept-encoding'], path)); const stat = await asyncFstat(fd); const hash = isDist ? undefined : await getFileHash(fileHashCache, path, stat, fd); @@ -113,6 +150,12 @@ export async function createDynamicAssetResponse({ response.header('cache-control', 'must-revalidate'); } + // If we manually selected a compressed file, specify the encoding header. + // Otherwise, let Hapi automatically gzip the response. + if (fileEncoding) { + response.header('content-encoding', fileEncoding); + } + return response; } catch (error) { if (fd) { diff --git a/test/functional/apps/bundles/index.js b/test/functional/apps/bundles/index.js new file mode 100644 index 00000000000000..8a25c7cd1fafca --- /dev/null +++ b/test/functional/apps/bundles/index.js @@ -0,0 +1,73 @@ +/* + * 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. + */ + +/** + * These supertest-based tests live in the functional test suite because they depend on the optimizer bundles being built + * and served + */ +export default function({ getService }) { + const supertest = getService('supertest'); + + describe('bundle compression', function() { + this.tags('ciGroup12'); + + let buildNum; + before(async () => { + const resp = await supertest.get('/api/status').expect(200); + buildNum = resp.body.version.build_number; + }); + + it('returns gzip files when client only supports gzip', () => + supertest + // We use the kbn-ui-shared-deps for these tests since they are always built with br compressed outputs, + // even in dev. Bundles built by @kbn/optimizer are only built with br compression in dist mode. + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'gzip') + .expect(200) + .expect('Content-Encoding', 'gzip')); + + it('returns br files when client only supports br', () => + supertest + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'br') + .expect(200) + .expect('Content-Encoding', 'br')); + + it('returns br files when client only supports gzip and br', () => + supertest + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'gzip, br') + .expect(200) + .expect('Content-Encoding', 'br')); + + it('returns gzip files when client prefers gzip', () => + supertest + .get(`/${buildNum}/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.js`) + .set('Accept-Encoding', 'gzip;q=1.0, br;q=0.5') + .expect(200) + .expect('Content-Encoding', 'gzip')); + + it('returns gzip files when no brotli version exists', () => + supertest + .get(`/${buildNum}/bundles/commons.style.css`) // legacy optimizer does not create brotli outputs + .set('Accept-Encoding', 'gzip, br') + .expect(200) + .expect('Content-Encoding', 'gzip')); + }); +} diff --git a/test/functional/config.js b/test/functional/config.js index 0fbde95afe12c7..8cc0a34e352a92 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -25,11 +25,12 @@ export default async function({ readConfigFile }) { return { testFiles: [ + require.resolve('./apps/bundles'), require.resolve('./apps/console'), - require.resolve('./apps/getting_started'), require.resolve('./apps/context'), require.resolve('./apps/dashboard'), require.resolve('./apps/discover'), + require.resolve('./apps/getting_started'), require.resolve('./apps/home'), require.resolve('./apps/management'), require.resolve('./apps/saved_objects_management'), diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index a10bb013b3af48..02ed9e9865d9a3 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -51,6 +51,7 @@ import { ToastsProvider } from './toasts'; import { PieChartProvider } from './visualizations'; import { ListingTableProvider } from './listing_table'; import { SavedQueryManagementComponentProvider } from './saved_query_management_component'; +import { KibanaSupertestProvider } from './supertest'; export const services = { ...commonServiceProviders, @@ -83,4 +84,5 @@ export const services = { toasts: ToastsProvider, savedQueryManagementComponent: SavedQueryManagementComponentProvider, elasticChart: ElasticChartProvider, + supertest: KibanaSupertestProvider, }; diff --git a/test/functional/services/supertest.ts b/test/functional/services/supertest.ts new file mode 100644 index 00000000000000..30c7db87a8f8bf --- /dev/null +++ b/test/functional/services/supertest.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 { FtrProviderContext } from 'test/functional/ftr_provider_context'; +import { format as formatUrl } from 'url'; + +import supertestAsPromised from 'supertest-as-promised'; + +export function KibanaSupertestProvider({ getService }: FtrProviderContext) { + const config = getService('config'); + const kibanaServerUrl = formatUrl(config.get('servers.kibana')); + return supertestAsPromised(kibanaServerUrl); +} diff --git a/typings/accept.d.ts b/typings/accept.d.ts new file mode 100644 index 00000000000000..69cadc7491eeb4 --- /dev/null +++ b/typings/accept.d.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. + */ + +declare module 'accept' { + // @types/accept does not include the `preferences` argument so we override the type to include it + export function encodings(encodingHeader?: string, preferences?: string[]): string[]; +} diff --git a/yarn.lock b/yarn.lock index 3c233b76f1a48e..941143e76483e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3624,6 +3624,11 @@ dependencies: "@turf/helpers" "6.x" +"@types/accept@3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/accept/-/accept-3.1.1.tgz#74457f6afabd23181e32b6bafae238bda0ce0da7" + integrity sha512-pXwi0bKUriKuNUv7d1xwbxKTqyTIzmMr1StxcGARmiuTLQyjNo+YwDq0w8dzY8wQjPofdgs1hvQLTuJaGuSKiQ== + "@types/angular-mocks@^1.7.0": version "1.7.0" resolved "https://registry.yarnpkg.com/@types/angular-mocks/-/angular-mocks-1.7.0.tgz#310d999a3c47c10ecd8eef466b5861df84799429" @@ -3852,6 +3857,13 @@ dependencies: "@types/color-convert" "*" +"@types/compression-webpack-plugin@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/compression-webpack-plugin/-/compression-webpack-plugin-2.0.1.tgz#4db78c398c8e973077cc530014d6513f1c693951" + integrity sha512-40oKg2aByfUPShpYBkldYwOcO34yaqOIPdlUlR1+F3MFl2WfpqYq2LFKOcgjU70d1r1L8r99XHkxYdhkGajHSw== + dependencies: + "@types/webpack" "*" + "@types/cookiejar@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.0.tgz#4b7daf2c51696cfc70b942c11690528229d1a1ce" @@ -5454,7 +5466,7 @@ abortcontroller-polyfill@^1.4.0: resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.4.0.tgz#0d5eb58e522a461774af8086414f68e1dda7a6c4" integrity sha512-3ZFfCRfDzx3GFjO6RAkYx81lPGpUS20ISxux9gLxuKnqafNcFQo59+IoZqpO2WvQlyc287B62HDnDdNYRmlvWA== -accept@3.x.x: +accept@3.0.2, accept@3.x.x: version "3.0.2" resolved "https://registry.yarnpkg.com/accept/-/accept-3.0.2.tgz#83e41cec7e1149f3fd474880423873db6c6cc9ac" integrity sha512-bghLXFkCOsC1Y2TZ51etWfKDs6q249SAoHTZVfzWWdlZxoij+mgkj9AmUJWQpDY48TfnrTDIe43Xem4zdMe7mQ== @@ -9501,6 +9513,18 @@ compressible@~2.0.16: dependencies: mime-db ">= 1.40.0 < 2" +compression-webpack-plugin@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-3.1.0.tgz#9f510172a7b5fae5aad3b670652e8bd7997aeeca" + integrity sha512-iqTHj3rADN4yHwXMBrQa/xrncex/uEQy8QHlaTKxGchT/hC0SdlJlmL/5eRqffmWq2ep0/Romw6Ld39JjTR/ug== + dependencies: + cacache "^13.0.1" + find-cache-dir "^3.0.0" + neo-async "^2.5.0" + schema-utils "^2.6.1" + serialize-javascript "^2.1.2" + webpack-sources "^1.0.1" + compression@^1.7.4: version "1.7.4" resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" @@ -31654,18 +31678,18 @@ webpack-merge@4.2.2, webpack-merge@^4.2.2: dependencies: lodash "^4.17.15" -webpack-sources@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.3.0.tgz#2a28dcb9f1f45fe960d8f1493252b5ee6530fa85" - integrity sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA== +webpack-sources@^1.0.1, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" + integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== dependencies: source-list-map "^2.0.0" source-map "~0.6.1" -webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" - integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== +webpack-sources@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.3.0.tgz#2a28dcb9f1f45fe960d8f1493252b5ee6530fa85" + integrity sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA== dependencies: source-list-map "^2.0.0" source-map "~0.6.1" From 6ab1b20eeb5587d66535ff9c97f54370b67ea0a1 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Mon, 4 May 2020 10:53:47 -0600 Subject: [PATCH 04/59] Display global loading bar while applications are mounting (#64556) --- .../application_service.test.ts.snap | 1 + .../application/application_service.test.ts | 77 +++++++++++++++- .../application/application_service.tsx | 4 + .../integration_tests/router.test.tsx | 30 ++++--- .../application/integration_tests/utils.tsx | 5 +- .../public/application/ui/app_container.scss | 25 ++++++ .../application/ui/app_container.test.tsx | 90 ++++++++++++++++++- .../public/application/ui/app_container.tsx | 35 ++++++-- src/core/public/application/ui/app_router.tsx | 6 +- 9 files changed, 242 insertions(+), 31 deletions(-) create mode 100644 src/core/public/application/ui/app_container.scss diff --git a/src/core/public/application/__snapshots__/application_service.test.ts.snap b/src/core/public/application/__snapshots__/application_service.test.ts.snap index 376b320b64ea9a..c085fb028cd5a7 100644 --- a/src/core/public/application/__snapshots__/application_service.test.ts.snap +++ b/src/core/public/application/__snapshots__/application_service.test.ts.snap @@ -80,5 +80,6 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = ` } mounters={Map {}} setAppLeaveHandler={[Function]} + setIsMounting={[Function]} /> `; diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index e29837aecb1253..04ff844ffc1505 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -20,7 +20,7 @@ import { createElement } from 'react'; import { BehaviorSubject, Subject } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; @@ -30,6 +30,7 @@ import { MockCapabilitiesService, MockHistory } from './application_service.test import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; import { App, AppNavLinkStatus, AppStatus, AppUpdater, LegacyApp } from './types'; +import { act } from 'react-dom/test-utils'; const createApp = (props: Partial): App => { return { @@ -452,9 +453,9 @@ describe('#setup()', () => { const container = setupDeps.context.createContextContainer.mock.results[0].value; const pluginId = Symbol(); - const mount = () => () => undefined; - registerMountContext(pluginId, 'test' as any, mount); - expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); + const appMount = () => () => undefined; + registerMountContext(pluginId, 'test' as any, appMount); + expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', appMount); }); }); @@ -809,6 +810,74 @@ describe('#start()', () => { `); }); + it('updates httpLoadingCount$ while mounting', async () => { + // Use a memory history so that mounting the component will work + const { createMemoryHistory } = jest.requireActual('history'); + const history = createMemoryHistory(); + setupDeps.history = history; + + const flushPromises = () => new Promise(resolve => setImmediate(resolve)); + // Create an app and a promise that allows us to control when the app completes mounting + const createWaitingApp = (props: Partial): [App, () => void] => { + let finishMount: () => void; + const mountPromise = new Promise(resolve => (finishMount = resolve)); + const app = { + id: 'some-id', + title: 'some-title', + mount: async () => { + await mountPromise; + return () => undefined; + }, + ...props, + }; + + return [app, finishMount!]; + }; + + // Create some dummy applications + const { register } = service.setup(setupDeps); + const [alphaApp, finishAlphaMount] = createWaitingApp({ id: 'alpha' }); + const [betaApp, finishBetaMount] = createWaitingApp({ id: 'beta' }); + register(Symbol(), alphaApp); + register(Symbol(), betaApp); + + const { navigateToApp, getComponent } = await service.start(startDeps); + const httpLoadingCount$ = startDeps.http.addLoadingCountSource.mock.calls[0][0]; + const stop$ = new Subject(); + const currentLoadingCount$ = new BehaviorSubject(0); + httpLoadingCount$.pipe(takeUntil(stop$)).subscribe(currentLoadingCount$); + const loadingPromise = httpLoadingCount$.pipe(bufferCount(5), takeUntil(stop$)).toPromise(); + mount(getComponent()!); + + await act(() => navigateToApp('alpha')); + expect(currentLoadingCount$.value).toEqual(1); + await act(async () => { + finishAlphaMount(); + await flushPromises(); + }); + expect(currentLoadingCount$.value).toEqual(0); + + await act(() => navigateToApp('beta')); + expect(currentLoadingCount$.value).toEqual(1); + await act(async () => { + finishBetaMount(); + await flushPromises(); + }); + expect(currentLoadingCount$.value).toEqual(0); + + stop$.next(); + const loadingCounts = await loadingPromise; + expect(loadingCounts).toMatchInlineSnapshot(` + Array [ + 0, + 1, + 0, + 1, + 0, + ] + `); + }); + it('sets window.location.href when navigating to legacy apps', async () => { setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' }); setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index bafa1932e5e927..0dd77072e9eafb 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -238,6 +238,9 @@ export class ApplicationService { throw new Error('ApplicationService#setup() must be invoked before start.'); } + const httpLoadingCount$ = new BehaviorSubject(0); + http.addLoadingCountSource(httpLoadingCount$); + this.registrationClosed = true; window.addEventListener('beforeunload', this.onBeforeUnload); @@ -303,6 +306,7 @@ export class ApplicationService { mounters={availableMounters} appStatuses$={applicationStatuses$} setAppLeaveHandler={this.setAppLeaveHandler} + setIsMounting={isMounting => httpLoadingCount$.next(isMounting ? 1 : 0)} /> ); }, diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 2f26bc1409104d..915c58b28ad6d1 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -40,7 +40,7 @@ describe('AppContainer', () => { }; const mockMountersToMounters = () => new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter])); - const setAppLeaveHandlerMock = () => undefined; + const noop = () => undefined; const mountersToAppStatus$ = () => { return new BehaviorSubject( @@ -86,7 +86,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={appStatuses$} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); }); @@ -98,7 +99,7 @@ describe('AppContainer', () => { expect(app1.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app1 html: App 1
" @@ -110,7 +111,7 @@ describe('AppContainer', () => { expect(app1Unmount).toHaveBeenCalled(); expect(app2.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app2 html:
App 2
" @@ -124,7 +125,7 @@ describe('AppContainer', () => { expect(standardApp.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app1 html: App 1
" @@ -136,7 +137,7 @@ describe('AppContainer', () => { expect(standardAppUnmount).toHaveBeenCalled(); expect(chromelessApp.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-a/path html:
Chromeless A
" @@ -148,7 +149,7 @@ describe('AppContainer', () => { expect(chromelessAppUnmount).toHaveBeenCalled(); expect(standardApp.mounter.mount).toHaveBeenCalledTimes(2); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /app/app1 html: App 1
" @@ -162,7 +163,7 @@ describe('AppContainer', () => { expect(chromelessAppA.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-a/path html:
Chromeless A
" @@ -174,7 +175,7 @@ describe('AppContainer', () => { expect(chromelessAppAUnmount).toHaveBeenCalled(); expect(chromelessAppB.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-b/path html:
Chromeless B
" @@ -186,7 +187,7 @@ describe('AppContainer', () => { expect(chromelessAppBUnmount).toHaveBeenCalled(); expect(chromelessAppA.mounter.mount).toHaveBeenCalledTimes(2); expect(dom?.html()).toMatchInlineSnapshot(` - "
+ "
basename: /chromeless-a/path html:
Chromeless A
" @@ -214,7 +215,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); @@ -245,7 +247,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); @@ -286,7 +289,8 @@ describe('AppContainer', () => { history={globalHistory} mounters={mockMountersToMounters()} appStatuses$={mountersToAppStatus$()} - setAppLeaveHandler={setAppLeaveHandlerMock} + setAppLeaveHandler={noop} + setIsMounting={noop} /> ); diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx index 9092177da5ad4b..fa04b56f83ba1d 100644 --- a/src/core/public/application/integration_tests/utils.tsx +++ b/src/core/public/application/integration_tests/utils.tsx @@ -18,6 +18,7 @@ */ import React, { ReactElement } from 'react'; +import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; @@ -34,7 +35,9 @@ export const createRenderer = (element: ReactElement | null): Renderer => { return () => new Promise(async resolve => { if (dom) { - dom.update(); + await act(async () => { + dom.update(); + }); } setImmediate(() => resolve(dom)); // flushes any pending promises }); diff --git a/src/core/public/application/ui/app_container.scss b/src/core/public/application/ui/app_container.scss new file mode 100644 index 00000000000000..4f8fec10a97e15 --- /dev/null +++ b/src/core/public/application/ui/app_container.scss @@ -0,0 +1,25 @@ +.appContainer__loading { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: $euiZLevel1; + animation-name: appContainerFadeIn; + animation-iteration-count: 1; + animation-timing-function: ease-in; + animation-duration: 2s; +} + +@keyframes appContainerFadeIn { + 0% { + opacity: 0; + } + + 50% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index c538227e8f0988..2ee71a5bde7dc1 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mount } from 'enzyme'; import { AppContainer } from './app_container'; @@ -28,6 +29,12 @@ import { ScopedHistory } from '../scoped_history'; describe('AppContainer', () => { const appId = 'someApp'; const setAppLeaveHandler = jest.fn(); + const setIsMounting = jest.fn(); + + beforeEach(() => { + setAppLeaveHandler.mockClear(); + setIsMounting.mockClear(); + }); const flushPromises = async () => { await new Promise(async resolve => { @@ -67,6 +74,7 @@ describe('AppContainer', () => { appStatus={AppStatus.inaccessible} mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} + setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) @@ -86,10 +94,86 @@ describe('AppContainer', () => { expect(wrapper.text()).toEqual(''); - resolvePromise(); - await flushPromises(); - wrapper.update(); + await act(async () => { + resolvePromise(); + await flushPromises(); + wrapper.update(); + }); expect(wrapper.text()).toContain('some-content'); }); + + it('should call setIsMounting while mounting', async () => { + const [waitPromise, resolvePromise] = createResolver(); + const mounter = createMounter(waitPromise); + + const wrapper = mount( + + // Create a history using the appPath as the current location + new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) + } + /> + ); + + expect(setIsMounting).toHaveBeenCalledTimes(1); + expect(setIsMounting).toHaveBeenLastCalledWith(true); + + await act(async () => { + resolvePromise(); + await flushPromises(); + wrapper.update(); + }); + + expect(setIsMounting).toHaveBeenCalledTimes(2); + expect(setIsMounting).toHaveBeenLastCalledWith(false); + }); + + it('should call setIsMounting(false) if mounting throws', async () => { + const [waitPromise, resolvePromise] = createResolver(); + const mounter = { + appBasePath: '/base-path', + appRoute: '/some-route', + unmountBeforeMounting: false, + mount: async ({ element }: AppMountParameters) => { + await waitPromise; + throw new Error(`Mounting failed!`); + }, + }; + + const wrapper = mount( + + // Create a history using the appPath as the current location + new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath) + } + /> + ); + + expect(setIsMounting).toHaveBeenCalledTimes(1); + expect(setIsMounting).toHaveBeenLastCalledWith(true); + + // await expect( + await act(async () => { + resolvePromise(); + await flushPromises(); + wrapper.update(); + }); + // ).rejects.toThrow(); + + expect(setIsMounting).toHaveBeenCalledTimes(2); + expect(setIsMounting).toHaveBeenLastCalledWith(false); + }); }); diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index e12a0f2cf2fcd0..aad7e6dcf270a7 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -26,9 +26,11 @@ import React, { MutableRefObject, } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { AppLeaveHandler, AppStatus, AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; import { ScopedHistory } from '../scoped_history'; +import './app_container.scss'; interface Props { /** Path application is mounted on without the global basePath */ @@ -38,6 +40,7 @@ interface Props { appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; createScopedHistory: (appUrl: string) => ScopedHistory; + setIsMounting: (isMounting: boolean) => void; } export const AppContainer: FunctionComponent = ({ @@ -47,7 +50,9 @@ export const AppContainer: FunctionComponent = ({ setAppLeaveHandler, createScopedHistory, appStatus, + setIsMounting, }: Props) => { + const [showSpinner, setShowSpinner] = useState(true); const [appNotFound, setAppNotFound] = useState(false); const elementRef = useRef(null); const unmountRef: MutableRefObject = useRef(null); @@ -65,28 +70,42 @@ export const AppContainer: FunctionComponent = ({ } setAppNotFound(false); + setIsMounting(true); if (mounter.unmountBeforeMounting) { unmount(); } const mount = async () => { - unmountRef.current = - (await mounter.mount({ - appBasePath: mounter.appBasePath, - history: createScopedHistory(appPath), - element: elementRef.current!, - onAppLeave: handler => setAppLeaveHandler(appId, handler), - })) || null; + setShowSpinner(true); + try { + unmountRef.current = + (await mounter.mount({ + appBasePath: mounter.appBasePath, + history: createScopedHistory(appPath), + element: elementRef.current!, + onAppLeave: handler => setAppLeaveHandler(appId, handler), + })) || null; + } catch (e) { + // TODO: add error UI + } finally { + setShowSpinner(false); + setIsMounting(false); + } }; mount(); return unmount; - }, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath]); + }, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath, setIsMounting]); return ( {appNotFound && } + {showSpinner && ( +
+ +
+ )}
); diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 4c135c57690676..ea7c5c9308fe2a 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -32,6 +32,7 @@ interface Props { history: History; appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; + setIsMounting: (isMounting: boolean) => void; } interface Params { @@ -43,6 +44,7 @@ export const AppRouter: FunctionComponent = ({ mounters, setAppLeaveHandler, appStatuses$, + setIsMounting, }) => { const appStatuses = useObservable(appStatuses$, new Map()); const createScopedHistory = useMemo( @@ -67,7 +69,7 @@ export const AppRouter: FunctionComponent = ({ appPath={url} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ appId, mounter, setAppLeaveHandler }} + {...{ appId, mounter, setAppLeaveHandler, setIsMounting }} /> )} />, @@ -92,7 +94,7 @@ export const AppRouter: FunctionComponent = ({ appId={id} appStatus={appStatuses.get(id) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ mounter, setAppLeaveHandler }} + {...{ mounter, setAppLeaveHandler, setIsMounting }} /> ); }} From 5e972e14d147e3c5232114690f61a96a493a28ae Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 4 May 2020 13:00:56 -0400 Subject: [PATCH 05/59] [Fleet] Better display of fleet requirements (#65027) --- .../ingest_manager/common/types/index.ts | 1 + .../common/types/rest_spec/fleet_setup.ts | 5 + x-pack/plugins/ingest_manager/kibana.json | 2 +- .../ingest_manager/hooks/use_fleet_status.tsx | 69 ++++++++++++ .../ingest_manager/hooks/use_request/index.ts | 1 + .../ingest_manager/hooks/use_request/setup.ts | 10 +- .../applications/ingest_manager/index.tsx | 5 +- .../agent_enrollment_flyout/index.tsx | 58 +++++++--- .../ingest_manager/sections/fleet/index.tsx | 18 ++- .../sections/fleet/setup_page/index.tsx | 103 ++++++++++++------ .../ingest_manager/types/index.ts | 2 + x-pack/plugins/ingest_manager/server/index.ts | 1 + .../plugins/ingest_manager/server/plugin.ts | 17 ++- .../server/routes/setup/handlers.ts | 47 +++++--- .../server/routes/setup/index.ts | 5 +- .../server/services/agent_config.ts | 10 +- .../server/services/app_context.ts | 27 ++++- .../server/services/datasource.ts | 3 + .../ingest_manager/server/services/output.ts | 5 +- .../ingest_manager/server/services/setup.ts | 7 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 22 files changed, 309 insertions(+), 91 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index 748bb14d2d35de..b357d0c2d75f48 100644 --- a/x-pack/plugins/ingest_manager/common/types/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/index.ts @@ -14,6 +14,7 @@ export interface IngestManagerConfigType { }; fleet: { enabled: boolean; + tlsCheckDisabled: boolean; defaultOutputHost: string; kibana: { host?: string; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts index c4ba8ee595acfc..ae4cb4e3fce498 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts @@ -7,3 +7,8 @@ export interface CreateFleetSetupResponse { isInitialized: boolean; } + +export interface GetFleetStatusResponse { + isReady: boolean; + missing_requirements: Array<'tls_required' | 'api_keys' | 'fleet_admin_user'>; +} diff --git a/x-pack/plugins/ingest_manager/kibana.json b/x-pack/plugins/ingest_manager/kibana.json index cef1a293c104bc..382ea0444093d4 100644 --- a/x-pack/plugins/ingest_manager/kibana.json +++ b/x-pack/plugins/ingest_manager/kibana.json @@ -5,5 +5,5 @@ "ui": true, "configPath": ["xpack", "ingestManager"], "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], - "optionalPlugins": ["security", "features"] + "optionalPlugins": ["security", "features", "cloud"] } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx new file mode 100644 index 00000000000000..ef40c171b9ca35 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_fleet_status.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useContext, useEffect } from 'react'; +import { useConfig } from './use_config'; +import { sendGetFleetStatus } from './use_request'; +import { GetFleetStatusResponse } from '../types'; + +interface FleetStatusState { + enabled: boolean; + isLoading: boolean; + isReady: boolean; + missingRequirements?: GetFleetStatusResponse['missing_requirements']; +} + +interface FleetStatus extends FleetStatusState { + refresh: () => Promise; +} + +const FleetStatusContext = React.createContext(undefined); + +export const FleetStatusProvider: React.FC = ({ children }) => { + const config = useConfig(); + const [state, setState] = useState({ + enabled: config.fleet.enabled, + isLoading: false, + isReady: false, + }); + async function sendGetStatus() { + try { + setState(s => ({ ...s, isLoading: true })); + const res = await sendGetFleetStatus(); + if (res.error) { + throw res.error; + } + + setState(s => ({ + ...s, + isLoading: false, + isReady: res.data?.isReady ?? false, + missingRequirements: res.data?.missing_requirements, + })); + } catch (error) { + setState(s => ({ ...s, isLoading: true })); + } + } + useEffect(() => { + sendGetStatus(); + }, []); + + return ( + sendGetStatus() }}> + {children} + + ); +}; + +export function useFleetStatus(): FleetStatus { + const context = useContext(FleetStatusContext); + + if (!context) { + throw new Error('FleetStatusContext not set'); + } + + return context; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts index c39d2a5860bf00..25cdffc5c6651b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts @@ -12,3 +12,4 @@ export * from './enrollment_api_keys'; export * from './epm'; export * from './outputs'; export * from './settings'; +export * from './setup'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts index 04fdf9f66948f0..e4e84e4701f139 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts @@ -5,7 +5,8 @@ */ import { sendRequest } from './use_request'; -import { setupRouteService } from '../../services'; +import { setupRouteService, fleetSetupRouteService } from '../../services'; +import { GetFleetStatusResponse } from '../../types'; export const sendSetup = () => { return sendRequest({ @@ -13,3 +14,10 @@ export const sendSetup = () => { method: 'post', }); }; + +export const sendGetFleetStatus = () => { + return sendRequest({ + path: fleetSetupRouteService.getFleetSetupPath(), + method: 'get', + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 295a35693726f3..f0a0c90a18c24e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -23,6 +23,7 @@ import { IngestManagerOverview, EPMApp, AgentConfigApp, FleetApp, DataStreamApp import { CoreContext, DepsContext, ConfigContext, setHttpClient, useConfig } from './hooks'; import { PackageInstallProvider } from './sections/epm/hooks'; import { sendSetup } from './hooks/use_request/setup'; +import { FleetStatusProvider } from './hooks/use_fleet_status'; import './index.scss'; export interface ProtectedRouteProps extends RouteProps { @@ -142,7 +143,9 @@ const IngestManagerApp = ({ - + + + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx index dd34e7260b27b4..e9347ccd2d6c92 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx @@ -15,11 +15,15 @@ import { EuiButtonEmpty, EuiButton, EuiFlyoutFooter, + EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentConfig } from '../../../../../types'; import { APIKeySelection } from './key_selection'; import { EnrollmentInstructions } from './instructions'; +import { useFleetStatus } from '../../../../../hooks/use_fleet_status'; +import { useLink } from '../../../../../hooks'; +import { FLEET_PATH } from '../../../../../constants'; interface Props { onClose: () => void; @@ -30,8 +34,11 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, agentConfigs = [], }) => { + const fleetStatus = useFleetStatus(); const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + const fleetLink = useLink(FLEET_PATH); + return ( @@ -45,12 +52,33 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ - setSelectedAPIKeyId(keyId)} - /> - - + {fleetStatus.isReady ? ( + <> + setSelectedAPIKeyId(keyId)} + /> + + + + ) : ( + <> + + + + ), + }} + /> + + )} @@ -62,14 +90,16 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ /> - - - - - + {fleetStatus.isReady && ( + + + + + + )} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx index fac81ecc19cd18..b9c5418dbf6f32 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx @@ -6,35 +6,31 @@ import React from 'react'; import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; import { Loading } from '../../components'; -import { useConfig, useCore, useRequest } from '../../hooks'; +import { useConfig, useCore } from '../../hooks'; import { AgentListPage } from './agent_list_page'; import { SetupPage } from './setup_page'; import { AgentDetailsPage } from './agent_details_page'; import { NoAccessPage } from './error_pages/no_access'; -import { fleetSetupRouteService } from '../../services'; import { EnrollmentTokenListPage } from './enrollment_token_list_page'; import { ListLayout } from './components/list_layout'; +import { useFleetStatus } from '../../hooks/use_fleet_status'; export const FleetApp: React.FunctionComponent = () => { const core = useCore(); const { fleet } = useConfig(); - const setupRequest = useRequest({ - method: 'get', - path: fleetSetupRouteService.getFleetSetupPath(), - }); + const fleetStatus = useFleetStatus(); if (!fleet.enabled) return null; - if (setupRequest.isLoading) { + if (fleetStatus.isLoading) { return ; } - if (setupRequest.data.isInitialized === false) { + if (fleetStatus.isReady === false) { return ( { - await setupRequest.sendRequest(); - }} + missingRequirements={fleetStatus.missingRequirements || []} + refresh={fleetStatus.refresh} /> ); } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx index 96d4d01d67a498..4d89268c14b281 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx @@ -18,10 +18,12 @@ import { import { sendRequest, useCore } from '../../../hooks'; import { fleetSetupRouteService } from '../../../services'; import { WithoutHeaderLayout } from '../../../layouts'; +import { GetFleetStatusResponse } from '../../../types'; export const SetupPage: React.FunctionComponent<{ refresh: () => Promise; -}> = ({ refresh }) => { + missingRequirements: GetFleetStatusResponse['missing_requirements']; +}> = ({ refresh, missingRequirements }) => { const [isFormLoading, setIsFormLoading] = useState(false); const core = useCore(); @@ -40,46 +42,81 @@ export const SetupPage: React.FunctionComponent<{ } }; + const content = + missingRequirements.includes('tls_required') || missingRequirements.includes('api_keys') ? ( + <> + + + + +

+ +

+
+ + + , + }} + /> + + + + ) : ( + <> + + + + +

+ +

+
+ + + + + + +
+ + + +
+
+ + + ); + return ( - + - - - - -

- -

-
- - - - - - -
- - - -
-
- + {content}
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index 602015d23cefb9..ca5bf999aa81a3 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -20,6 +20,8 @@ export { DatasourceConfigRecordEntry, Output, DataStream, + // API schema - misc setup, status + GetFleetStatusResponse, // API schemas - Agent Config GetAgentConfigsResponse, GetAgentConfigsResponseItem, diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 951ff2337d8c72..6096af8d808010 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -26,6 +26,7 @@ export const config = { }), fleet: schema.object({ enabled: schema.boolean({ defaultValue: true }), + tlsCheckDisabled: schema.boolean({ defaultValue: false }), kibana: schema.object({ host: schema.maybe(schema.string()), ca_sha256: schema.maybe(schema.string()), diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 3448685d1f279f..3b0837565c36cd 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -11,6 +11,7 @@ import { Plugin, PluginInitializerContext, SavedObjectsServiceStart, + HttpServerInfo, } from 'kibana/server'; import { LicensingPluginSetup, ILicense } from '../../licensing/server'; import { @@ -42,7 +43,6 @@ import { registerOutputRoutes, registerSettingsRoutes, } from './routes'; - import { IngestManagerConfigType } from '../common'; import { appContextService, @@ -52,12 +52,14 @@ import { AgentService, } from './services'; import { getAgentStatusById } from './services/agents'; +import { CloudSetup } from '../../cloud/server'; export interface IngestManagerSetupDeps { licensing: LicensingPluginSetup; security?: SecurityPluginSetup; features?: FeaturesPluginSetup; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; + cloud?: CloudSetup; } export type IngestManagerStartDeps = object; @@ -67,6 +69,9 @@ export interface IngestManagerAppContext { security?: SecurityPluginSetup; config$?: Observable; savedObjects: SavedObjectsServiceStart; + isProductionMode: boolean; + serverInfo?: HttpServerInfo; + cloud?: CloudSetup; } export type IngestManagerSetupContract = void; @@ -100,16 +105,23 @@ export class IngestManagerPlugin private licensing$!: Observable; private config$: Observable; private security: SecurityPluginSetup | undefined; + private cloud: CloudSetup | undefined; + + private isProductionMode: boolean; + private serverInfo: HttpServerInfo | undefined; constructor(private readonly initializerContext: PluginInitializerContext) { this.config$ = this.initializerContext.config.create(); + this.isProductionMode = this.initializerContext.env.mode.prod; } public async setup(core: CoreSetup, deps: IngestManagerSetupDeps) { + this.serverInfo = core.http.getServerInfo(); this.licensing$ = deps.licensing.license$; if (deps.security) { this.security = deps.security; } + this.cloud = deps.cloud; registerSavedObjects(core.savedObjects); registerEncryptedSavedObjects(deps.encryptedSavedObjects); @@ -184,6 +196,9 @@ export class IngestManagerPlugin security: this.security, config$: this.config$, savedObjects: core.savedObjects, + isProductionMode: this.isProductionMode, + serverInfo: this.serverInfo, + cloud: this.cloud, }); licenseService.start(this.licensing$); return { diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts index 837e73b966feba..542dfa9cefe8f9 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts @@ -4,28 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ import { RequestHandler } from 'src/core/server'; -import { outputService } from '../../services'; -import { CreateFleetSetupResponse } from '../../../common'; +import { outputService, appContextService } from '../../services'; +import { GetFleetStatusResponse } from '../../../common'; import { setupIngestManager, setupFleet } from '../../services/setup'; -export const getFleetSetupHandler: RequestHandler = async (context, request, response) => { +export const getFleetStatusHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; - const successBody: CreateFleetSetupResponse = { isInitialized: true }; - const failureBody: CreateFleetSetupResponse = { isInitialized: false }; try { - const adminUser = await outputService.getAdminUser(soClient); - if (adminUser) { - return response.ok({ - body: successBody, - }); - } else { - return response.ok({ - body: failureBody, - }); + const isAdminUserSetup = (await outputService.getAdminUser(soClient)) !== null; + const isApiKeysEnabled = await appContextService.getSecurity().authc.areAPIKeysEnabled(); + const isTLSEnabled = appContextService.getServerInfo().protocol === 'https'; + const isProductionMode = appContextService.getIsProductionMode(); + const isCloud = appContextService.getCloud()?.isCloudEnabled ?? false; + const isTLSCheckDisabled = appContextService.getConfig()?.fleet?.tlsCheckDisabled ?? false; + + const missingRequirements: GetFleetStatusResponse['missing_requirements'] = []; + if (!isAdminUserSetup) { + missingRequirements.push('fleet_admin_user'); } - } catch (e) { + if (!isApiKeysEnabled) { + missingRequirements.push('api_keys'); + } + if (!isTLSCheckDisabled && !isCloud && isProductionMode && !isTLSEnabled) { + missingRequirements.push('tls_required'); + } + + const body: GetFleetStatusResponse = { + isReady: missingRequirements.length === 0, + missing_requirements: missingRequirements, + }; + return response.ok({ - body: failureBody, + body, + }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, }); } }; diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/index.ts b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts index 5ee7ee7733220d..43dcf47d26c182 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import { IRouter } from 'src/core/server'; + import { PLUGIN_ID, FLEET_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants'; import { IngestManagerConfigType } from '../../../common'; import { - getFleetSetupHandler, + getFleetStatusHandler, createFleetSetupHandler, ingestManagerSetupHandler, } from './handlers'; @@ -36,7 +37,7 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) validate: false, options: { tags: [`access:${PLUGIN_ID}-read`] }, }, - getFleetSetupHandler + getFleetStatusHandler ); // Create Fleet setup diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 5ecbaff8ad71e1..84bcd7db3f7b1e 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -314,10 +314,12 @@ class AgentConfigService { if (!config) { return null; } - const defaultOutput = await outputService.get( - soClient, - await outputService.getDefaultOutputId(soClient) - ); + + const defaultOutputId = await outputService.getDefaultOutputId(soClient); + if (!defaultOutputId) { + throw new Error('Default output is not setup'); + } + const defaultOutput = await outputService.get(soClient, defaultOutputId); const agentConfig: FullAgentConfig = { id: config.id, diff --git a/x-pack/plugins/ingest_manager/server/services/app_context.ts b/x-pack/plugins/ingest_manager/server/services/app_context.ts index e917d2edd13090..5e538ad84b4c21 100644 --- a/x-pack/plugins/ingest_manager/server/services/app_context.ts +++ b/x-pack/plugins/ingest_manager/server/services/app_context.ts @@ -5,11 +5,12 @@ */ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { SavedObjectsServiceStart } from 'src/core/server'; +import { SavedObjectsServiceStart, HttpServerInfo } from 'src/core/server'; import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server'; import { SecurityPluginSetup } from '../../../security/server'; import { IngestManagerConfigType } from '../../common'; import { IngestManagerAppContext } from '../plugin'; +import { CloudSetup } from '../../../cloud/server'; class AppContextService { private encryptedSavedObjects: EncryptedSavedObjectsPluginStart | undefined; @@ -17,11 +18,17 @@ class AppContextService { private config$?: Observable; private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; + private serverInfo: HttpServerInfo | undefined; + private isProductionMode: boolean = false; + private cloud?: CloudSetup; public async start(appContext: IngestManagerAppContext) { this.encryptedSavedObjects = appContext.encryptedSavedObjects; this.security = appContext.security; this.savedObjects = appContext.savedObjects; + this.serverInfo = appContext.serverInfo; + this.isProductionMode = appContext.isProductionMode; + this.cloud = appContext.cloud; if (appContext.config$) { this.config$ = appContext.config$; @@ -41,9 +48,16 @@ class AppContextService { } public getSecurity() { + if (!this.security) { + throw new Error('Secury service not set.'); + } return this.security; } + public getCloud() { + return this.cloud; + } + public getConfig() { return this.configSubject$?.value; } @@ -58,6 +72,17 @@ class AppContextService { } return this.savedObjects; } + + public getIsProductionMode() { + return this.isProductionMode; + } + + public getServerInfo() { + if (!this.serverInfo) { + throw new Error('Server info not set.'); + } + return this.serverInfo; + } } export const appContextService = new AppContextService(); diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts index affd9b2755881a..0497bc5a2b541b 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts @@ -196,6 +196,9 @@ class DatasourceService { outputService.getDefaultOutputId(soClient), ]); if (pkgInfo) { + if (!defaultOutputId) { + throw new Error('Default output is not set'); + } return packageToConfigDatasource(pkgInfo, '', defaultOutputId); } } diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts index 395c9af4a4ca21..3628c5bd9e1830 100644 --- a/x-pack/plugins/ingest_manager/server/services/output.ts +++ b/x-pack/plugins/ingest_manager/server/services/output.ts @@ -48,7 +48,7 @@ class OutputService { }); if (!outputs.saved_objects.length) { - throw new Error('No default output'); + return null; } return outputs.saved_objects[0].id; @@ -56,6 +56,9 @@ class OutputService { public async getAdminUser(soClient: SavedObjectsClientContract) { const defaultOutputId = await this.getDefaultOutputId(soClient); + if (!defaultOutputId) { + return null; + } const so = await appContextService .getEncryptedSavedObjects() ?.getDecryptedAsInternalUser(OUTPUT_SAVED_OBJECT_TYPE, defaultOutputId); diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index 390e240841611e..3619628bd4f8b9 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -109,7 +109,12 @@ export async function setupFleet( }); // save fleet admin user - await outputService.updateOutput(soClient, await outputService.getDefaultOutputId(soClient), { + const defaultOutputId = await outputService.getDefaultOutputId(soClient); + if (!defaultOutputId) { + throw new Error('Default output does not exist'); + } + + await outputService.updateOutput(soClient, defaultOutputId, { fleet_enroll_username: FLEET_ENROLL_USERNAME, fleet_enroll_password: password, }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index da8673da67f422..11d1f864a36190 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8370,9 +8370,7 @@ "xpack.ingestManager.noAccess.accessDeniedTitle": "アクセスが拒否されました", "xpack.ingestManager.overviewPageSubtitle": "Ingest Manager についてのロレムイプサム説明文。", "xpack.ingestManager.overviewPageTitle": "Ingest Manager", - "xpack.ingestManager.setupPage.description": "フリートを使用するには、Elastic ユーザーを作成する必要があります。このユーザーは、API キーを作成して、logs-* および metrics-* に書き込むことができます。", "xpack.ingestManager.setupPage.enableFleet": "ユーザーを作成してフリートを有効にます", - "xpack.ingestManager.setupPage.title": "フリートを有効にする", "xpack.ingestManager.unenrollAgents.confirmModal.cancelButtonLabel": "キャンセル", "xpack.ingestManager.unenrollAgents.confirmModal.confirmButtonLabel": "登録解除", "xpack.ingestManager.unenrollAgents.confirmModal.deleteMultipleTitle": "{count, plural, one {# エージェント} other {# エージェント}}の登録を解除しますか?", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f66e9631b01686..9ee3b3d8f5931d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8376,9 +8376,7 @@ "xpack.ingestManager.noAccess.accessDeniedTitle": "访问被拒绝", "xpack.ingestManager.overviewPageSubtitle": "Lorem ipsum some description about ingest manager.", "xpack.ingestManager.overviewPageTitle": "Ingest Manager", - "xpack.ingestManager.setupPage.description": "要使用 Fleet,必须创建 Elastic 用户。此用户可以创建 API 密钥并写入到 logs-* and metrics-*。", "xpack.ingestManager.setupPage.enableFleet": "创建用户并启用 Fleet", - "xpack.ingestManager.setupPage.title": "启用 Fleet", "xpack.ingestManager.unenrollAgents.confirmModal.cancelButtonLabel": "取消", "xpack.ingestManager.unenrollAgents.confirmModal.confirmButtonLabel": "取消注册", "xpack.ingestManager.unenrollAgents.confirmModal.deleteMultipleTitle": "取消注册 {count, plural, one {# 个代理} other {# 个代理}}?", From 122450a4c889f50fc8a96ad30d849b7a260ad025 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 4 May 2020 18:04:42 +0100 Subject: [PATCH 06/59] chore(NA): skip functional test for visualize axis scalling preventing es snapshot promotion (#65100) --- test/functional/apps/visualize/_area_chart.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/visualize/_area_chart.js b/test/functional/apps/visualize/_area_chart.js index 8f2012d7f184d0..05544029f62d79 100644 --- a/test/functional/apps/visualize/_area_chart.js +++ b/test/functional/apps/visualize/_area_chart.js @@ -242,7 +242,9 @@ export default function({ getService, getPageObjects }) { await inspector.close(); }); - it('does not scale top hit agg', async () => { + // Preventing ES Promotion for master (8.0) + // https://github.com/elastic/kibana/issues/64734 + it.skip('does not scale top hit agg', async () => { const expectedTableData = [ ['2015-09-20 00:00', '6', '9.035KB'], ['2015-09-20 01:00', '9', '5.854KB'], From 9db27dba56c245a118fd03ffa4e31475195ffbfe Mon Sep 17 00:00:00 2001 From: Ross Wolf <31489089+rw-access@users.noreply.github.com> Date: Mon, 4 May 2020 11:07:09 -0600 Subject: [PATCH 07/59] [SIEM] Remove forgotten rules that weren't deleted (#64974) * Remove stray rules that should've been deleted * Update rule.ts and tests * Remove deleted prebuilt rules from cypress ES archive (#1) --- x-pack/plugins/siem/cypress/objects/rule.ts | 2 +- .../rules/prepackaged_rules/index.ts | 39 ++++++------- .../windows_execution_via_regsvr32.json | 51 ----------------- ...windows_signed_binary_proxy_execution.json | 54 ------------------ ...uspicious_process_started_by_a_script.json | 54 ------------------ .../prebuilt_rules_loaded/data.json.gz | Bin 41865 -> 41851 bytes 6 files changed, 18 insertions(+), 182 deletions(-) delete mode 100644 x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_regsvr32.json delete mode 100644 x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_signed_binary_proxy_execution.json delete mode 100644 x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_process_started_by_a_script.json diff --git a/x-pack/plugins/siem/cypress/objects/rule.ts b/x-pack/plugins/siem/cypress/objects/rule.ts index ce920aeb957af8..4e0189ea597da1 100644 --- a/x-pack/plugins/siem/cypress/objects/rule.ts +++ b/x-pack/plugins/siem/cypress/objects/rule.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export const totalNumberOfPrebuiltRules = 130; +export const totalNumberOfPrebuiltRules = 127; interface Mitre { tactic: string; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts index c24f5bb64ef5e4..9e185b5a5ef7c8 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -118,25 +118,23 @@ import rule108 from './windows_execution_msbuild_started_renamed.json'; import rule109 from './windows_execution_msbuild_started_unusal_process.json'; import rule110 from './windows_execution_via_compiled_html_file.json'; import rule111 from './windows_execution_via_net_com_assemblies.json'; -import rule112 from './windows_execution_via_regsvr32.json'; -import rule113 from './windows_execution_via_trusted_developer_utilities.json'; -import rule114 from './windows_html_help_executable_program_connecting_to_the_internet.json'; -import rule115 from './windows_injection_msbuild.json'; -import rule116 from './windows_misc_lolbin_connecting_to_the_internet.json'; -import rule117 from './windows_modification_of_boot_config.json'; -import rule118 from './windows_msxsl_network.json'; -import rule119 from './windows_net_command_system_account.json'; -import rule120 from './windows_persistence_via_application_shimming.json'; -import rule121 from './windows_priv_escalation_via_accessibility_features.json'; -import rule122 from './windows_process_discovery_via_tasklist_command.json'; -import rule123 from './windows_rare_user_runas_event.json'; -import rule124 from './windows_rare_user_type10_remote_login.json'; -import rule125 from './windows_register_server_program_connecting_to_the_internet.json'; -import rule126 from './windows_signed_binary_proxy_execution.json'; -import rule127 from './windows_suspicious_pdf_reader.json'; -import rule128 from './windows_suspicious_process_started_by_a_script.json'; -import rule129 from './windows_uac_bypass_event_viewer.json'; -import rule130 from './windows_whoami_command_activity.json'; +import rule112 from './windows_execution_via_trusted_developer_utilities.json'; +import rule113 from './windows_html_help_executable_program_connecting_to_the_internet.json'; +import rule114 from './windows_injection_msbuild.json'; +import rule115 from './windows_misc_lolbin_connecting_to_the_internet.json'; +import rule116 from './windows_modification_of_boot_config.json'; +import rule117 from './windows_msxsl_network.json'; +import rule118 from './windows_net_command_system_account.json'; +import rule119 from './windows_persistence_via_application_shimming.json'; +import rule120 from './windows_priv_escalation_via_accessibility_features.json'; +import rule121 from './windows_process_discovery_via_tasklist_command.json'; +import rule122 from './windows_rare_user_runas_event.json'; +import rule123 from './windows_rare_user_type10_remote_login.json'; +import rule124 from './windows_register_server_program_connecting_to_the_internet.json'; +import rule125 from './windows_suspicious_pdf_reader.json'; +import rule126 from './windows_uac_bypass_event_viewer.json'; +import rule127 from './windows_whoami_command_activity.json'; + export const rawRules = [ rule1, rule2, @@ -265,7 +263,4 @@ export const rawRules = [ rule125, rule126, rule127, - rule128, - rule129, - rule130, ]; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_regsvr32.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_regsvr32.json deleted file mode 100644 index e8e7ddfc168dcc..00000000000000 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_regsvr32.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "description": "Identifies scrobj.dll loaded into unusual Microsoft processes. This may indicate a malicious scriptlet is being executed in the target process.", - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "max_signals": 100, - "name": "Suspicious Script Object Execution", - "query": "event.code: 1 and scrobj.dll and (process.name:certutil.exe or process.name:regsvr32.exe or process.name:rundll32.exe)", - "risk_score": 21, - "rule_id": "b7333d08-be4b-4cb4-b81e-924ae37b3143", - "severity": "low", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ - { - "id": "T1064", - "name": "Scripting", - "reference": "https://attack.mitre.org/techniques/T1064/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ - { - "id": "T1064", - "name": "Scripting", - "reference": "https://attack.mitre.org/techniques/T1064/" - } - ] - } - ], - "type": "query", - "version": 1 -} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_signed_binary_proxy_execution.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_signed_binary_proxy_execution.json deleted file mode 100644 index be4ccef2a0887b..00000000000000 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_signed_binary_proxy_execution.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "description": "Binaries signed with trusted digital certificates can execute on Windows systems protected by digital signature validation. Adversaries may use these binaries to 'live off the land' and execute malicious files that could bypass application whitelisting and signature validation.", - "false_positives": [ - "Security testing may produce events like this. Activity of this kind performed by non-engineers and ordinary users is unusual." - ], - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "max_signals": 100, - "name": "Execution via Signed Binary", - "query": "event.code:1 and http and (process.name:certutil.exe or process.name:msiexec.exe)", - "risk_score": 21, - "rule_id": "7edb573f-1f9b-4161-8c19-c7c383bb17f2", - "severity": "low", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ - { - "id": "T1218", - "name": "Signed Binary Proxy Execution", - "reference": "https://attack.mitre.org/techniques/T1218/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ - { - "id": "T1218", - "name": "Signed Binary Proxy Execution", - "reference": "https://attack.mitre.org/techniques/T1218/" - } - ] - } - ], - "type": "query", - "version": 1 -} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_process_started_by_a_script.json b/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_process_started_by_a_script.json deleted file mode 100644 index 235a04f8063fcc..00000000000000 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_process_started_by_a_script.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "description": "Identifies a suspicious process being spawned from a script interpreter, which could be indicative of a potential phishing attack.", - "false_positives": [ - "Security testing may produce events like this. Activity of this kind performed by non-engineers and ordinary users is unusual." - ], - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "max_signals": 100, - "name": "Suspicious Process spawning from Script Interpreter", - "query": "(process.parent.name:cmd.exe or process.parent.name:cscript.exe or process.parent.name:mshta.exe or process.parent.name:powershell.exe or process.parent.name:rundll32.exe or process.parent.name:wscript.exe or process.parent.name:wmiprvse.exe) and (process.name:bitsadmin.exe or process.name:certutil.exe or mshta.exe or process.name:nslookup.exe or process.name:schtasks.exe) and event.code:1", - "risk_score": 21, - "rule_id": "89db767d-99f9-479f-8052-9205fd3090c4", - "severity": "low", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0005", - "name": "Defense Evasion", - "reference": "https://attack.mitre.org/tactics/TA0005/" - }, - "technique": [ - { - "id": "T1064", - "name": "Scripting", - "reference": "https://attack.mitre.org/techniques/T1064/" - } - ] - }, - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ - { - "id": "T1064", - "name": "Scripting", - "reference": "https://attack.mitre.org/techniques/T1064/" - } - ] - } - ], - "type": "query", - "version": 1 -} diff --git a/x-pack/test/siem_cypress/es_archives/prebuilt_rules_loaded/data.json.gz b/x-pack/test/siem_cypress/es_archives/prebuilt_rules_loaded/data.json.gz index 573c006d1507d9c84c581c9abab7d60d96bfebe3..cac63ed9c585f993b805b80657b9888a40f7d819 100644 GIT binary patch literal 41851 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

-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 Date: Mon, 4 May 2020 10:07:23 -0700 Subject: [PATCH 08/59] [Canvas] Updates function reference docs (#64741) --- .../canvas/canvas-function-reference.asciidoc | 271 +++++++++++++++++- 1 file changed, 256 insertions(+), 15 deletions(-) diff --git a/docs/canvas/canvas-function-reference.asciidoc b/docs/canvas/canvas-function-reference.asciidoc index 16aaf55802b170..657e3ec8b8bb18 100644 --- a/docs/canvas/canvas-function-reference.asciidoc +++ b/docs/canvas/canvas-function-reference.asciidoc @@ -42,10 +42,10 @@ filters | metric "Average uptime" metricFont={ font size=48 family="'Open Sans', Helvetica, Arial, sans-serif" - color={ - if {all {gte 0} {lt 0.8}} then="red" else="green" - } - align="center" lHeight=48 + color={ + if {all {gte 0} {lt 0.8}} then="red" else="green" + } + align="center" lHeight=48 } | render ---- @@ -324,12 +324,14 @@ case if={lte 50} then="green" ---- math "random()" | progress shape="gauge" label={formatnumber "0%"} - font={font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" align="center" - color={ - switch {case if={lte 0.5} then="green"} - {case if={all {gt 0.5} {lte 0.75}} then="orange"} - default="red" - }} + font={ + font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" align="center" + color={ + switch {case if={lte 0.5} then="green"} + {case if={all {gt 0.5} {lte 0.75}} then="orange"} + default="red" + } + } valueColor={ switch {case if={lte 0.5} then="green"} {case if={all {gt 0.5} {lte 0.75}} then="orange"} @@ -693,7 +695,25 @@ Alias: `value` [[demodata_fn]] === `demodata` -A mock data set that includes project CI times with usernames, countries, and run phases. +A sample data set that includes project CI times with usernames, countries, and run phases. + +*Expression syntax* +[source,js] +---- +demodata +demodata "ci" +demodata type="shirts" +---- + +*Code example* +[source,text] +---- +filters +| demodata +| table +| render +---- +`demodata` is a mock data set that you can use to start playing around in Canvas. *Accepts:* `filter` @@ -837,6 +857,28 @@ Alias: `value` Query Elasticsearch for the number of hits matching the specified query. +*Expression syntax* +[source,js] +---- +escount index="logstash-*" +escount "currency:"EUR"" index="kibana_sample_data_ecommerce" +escount query="response:404" index="kibana_sample_data_logs" +---- + +*Code example* +[source,text] +---- +filters +| escount "Cancelled:true" index="kibana_sample_data_flights" +| math "value" +| progress shape="semicircle" + label={formatnumber 0,0} + font={font size=24 family="'Open Sans', Helvetica, Arial, sans-serif" color="#000000" align=center} + max={filters | escount index="kibana_sample_data_flights"} +| render +---- +The first `escount` expression retrieves the number of flights that were cancelled. The second `escount` expression retrieves the total number of flights. + *Accepts:* `filter` [cols="3*^<"] @@ -867,6 +909,34 @@ Default: `_all` Query Elasticsearch for raw documents. Specify the fields you want to retrieve, especially if you are asking for a lot of rows. +*Expression syntax* +[source,js] +---- +esdocs index="logstash-*" +esdocs "currency:"EUR"" index="kibana_sample_data_ecommerce" +esdocs query="response:404" index="kibana_sample_data_logs" +esdocs index="kibana_sample_data_flights" count=100 +esdocs index="kibana_sample_data_flights" sort="AvgTicketPrice, asc" +---- + +*Code example* +[source,text] +---- +filters +| esdocs index="kibana_sample_data_ecommerce" + fields="customer_gender, taxful_total_price, order_date" + sort="order_date, asc" + count=10000 +| mapColumn "order_date" + fn={getCell "order_date" | date {context} | rounddate "YYYY-MM-DD"} +| alterColumn "order_date" type="date" +| pointseries x="order_date" y="sum(taxful_total_price)" color="customer_gender" +| plot defaultStyle={seriesStyle lines=3} + palette={palette "#7ECAE3" "#003A4D" gradient=true} +| render +---- +This retrieves the first 10000 documents data from the `kibana_sample_data_ecommerce` index sorted by `order_date` in ascending order, and only requests the `customer_gender`, `taxful_total_price`, and `order_date` fields. + *Accepts:* `filter` [cols="3*^<"] @@ -915,6 +985,23 @@ Default: `_all` Queries Elasticsearch using Elasticsearch SQL. +*Expression syntax* +[source,js] +---- +essql query="SELECT * FROM "logstash*"" +essql "SELECT * FROM "apm*"" count=10000 +---- + +*Code example* +[source,text] +---- +filters +| essql query="SELECT Carrier, FlightDelayMin, AvgTicketPrice FROM "kibana_sample_data_flights"" +| table +| render +---- +This retrieves the `Carrier`, `FlightDelayMin`, and `AvgTicketPrice` fields from the "kibana_sample_data_flights" index. + *Accepts:* `filter` [cols="3*^<"] @@ -1107,7 +1194,7 @@ Default: `false` [[font_fn]] === `font` -Creates a font style. +Create a font style. *Expression syntax* [source,js] @@ -1244,7 +1331,7 @@ Alias: `format` [[formatnumber_fn]] === `formatnumber` -Formats a number into a formatted number string using the <>. +Formats a number into a formatted number string using the Numeral pattern. *Expression syntax* [source,js] @@ -1276,7 +1363,7 @@ The `formatnumber` subexpression receives the same `context` as the `progress` f Alias: `format` |`string` -|A <> string. For example, `"0.0a"` or `"0%"`. +|A Numeral pattern format string. For example, `"0.0a"` or `"0%"`. |=== *Returns:* `string` @@ -1559,6 +1646,34 @@ Alias: `value` [[m_fns]] == M +[float] +[[mapCenter_fn]] +=== `mapCenter` + +Returns an object with the center coordinates and zoom level of the map. + +*Accepts:* `null` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`lat` *** +|`number` +|Latitude for the center of the map + +|`lon` *** +|`number` +|Longitude for the center of the map + +|`zoom` *** +|`number` +|Zoom level of the map +|=== + +*Returns:* `mapCenter` + + [float] [[mapColumn_fn]] === `mapColumn` @@ -1612,6 +1727,12 @@ Default: `""` |The CSS font properties for the content. For example, "font-family" or "font-weight". Default: `${font}` + +|`openLinksInNewTab` +|`boolean` +|A true or false value for opening links in a new tab. The default value is `false`. Setting to `true` opens all links in a new tab. + +Default: `false` |=== *Returns:* `render` @@ -1675,7 +1796,7 @@ Default: `${font size=48 family="'Open Sans', Helvetica, Arial, sans-serif" colo Alias: `format` |`string` -|A <> string. For example, `"0.0a"` or `"0%"`. +|A Numeral pattern format string. For example, `"0.0a"` or `"0%"`. |=== *Returns:* `render` @@ -2184,6 +2305,102 @@ Returns the number of rows. Pairs with <> to get the count of unique col [[s_fns]] == S +[float] +[[savedLens_fn]] +=== `savedLens` + +Returns an embeddable for a saved Lens visualization object. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`id` +|`string` +|The ID of the saved Lens visualization object + +|`timerange` +|`timerange` +|The timerange of data that should be included + +|`title` +|`string` +|The title for the Lens visualization object +|=== + +*Returns:* `embeddable` + + +[float] +[[savedMap_fn]] +=== `savedMap` + +Returns an embeddable for a saved map object. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`center` +|`mapCenter` +|The center and zoom level the map should have + +|`hideLayer` † +|`string` +|The IDs of map layers that should be hidden + +|`id` +|`string` +|The ID of the saved map object + +|`timerange` +|`timerange` +|The timerange of data that should be included + +|`title` +|`string` +|The title for the map +|=== + +*Returns:* `embeddable` + + +[float] +[[savedVisualization_fn]] +=== `savedVisualization` + +Returns an embeddable for a saved visualization object. + +*Accepts:* `any` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`colors` † +|`seriesStyle` +|Defines the color to use for a specific series + +|`hideLegend` +|`boolean` +|Specifies the option to hide the legend + +|`id` +|`string` +|The ID of the saved visualization object + +|`timerange` +|`timerange` +|The timerange of data that should be included +|=== + +*Returns:* `embeddable` + + [float] [[seriesStyle_fn]] === `seriesStyle` @@ -2579,6 +2796,30 @@ Default: `"now"` *Returns:* `datatable` +[float] +[[timerange_fn]] +=== `timerange` + +An object that represents a span of time. + +*Accepts:* `null` + +[cols="3*^<"] +|=== +|Argument |Type |Description + +|`from` *** +|`string` +|The start of the time range + +|`to` *** +|`string` +|The end of the time range +|=== + +*Returns:* `timerange` + + [float] [[to_fn]] === `to` From f62df99ae38801b905dd10d281dbf972c5b17578 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 4 May 2020 19:09:53 +0200 Subject: [PATCH 09/59] [ML] Embeddable Anomaly Swimlane (#64056) * [ML] embeddables setup * [ML] fix initialization * [ML] ts refactoring * [ML] refactor time_buckets.js * [ML] async services * [ML] extract job_selector_flyout.tsx * [ML] fetch overall swimlane data * [ML] import explorer styles * [ML] revert package_globs.ts * [ML] refactor with container, services with DI * [ML] resize throttle, fetch based on chart width * [ML] swimlane embeddable setup * [ML] explorer service * [ML] chart_tooltip_service * [ML] fix types * [ML] overall type for single job with no influencers * [ML] improve anomaly_swimlane_initializer ux * [ML] fix services initialization, unsubscribe on destroy * [ML] support custom time range * [ML] add tooltip * [ML] rollback initGetSwimlaneBucketInterval * [ML] new tooltip service * [ML] MlTooltipComponent with render props, fix warning * [ML] fix typo in the filename * [ML] remove redundant time range output * [ML] fix time_buckets.test.js jest tests * [ML] fix explorer chart tests * [ML] swimlane tests * [ML] store job ids instead of complete job objects * [ML] swimlane limit input * [ML] memo tooltip component, loading indicator * [ML] scrollable content * [ML] support query and filters * [ML] handle query syntax errors * [ML] rename anomaly_swimlane_service * [ML] introduce constants * [ML] edit panel title during setup * [ML] withTimeRangeSelector * [ML] rename explorer_service * [ML] getJobs$ method with one API call * [ML] fix groups selection * [ML] swimlane input resolver hook * [ML] useSwimlaneInputResolver tests * [ML] factory test * [ML] container test * [ML] set wrapper * [ML] tooltip tests * [ML] fix displayScore * [ML] label colors * [ML] support edit mode * [ML] call super render Co-authored-by: Elastic Machine --- x-pack/plugins/ml/kibana.json | 4 +- .../chart_tooltip/chart_tooltip.tsx | 182 +++++----- .../chart_tooltip/chart_tooltip_service.d.ts | 42 --- .../chart_tooltip/chart_tooltip_service.js | 37 -- .../chart_tooltip_service.test.ts | 63 +++- .../chart_tooltip/chart_tooltip_service.ts | 73 ++++ .../components/chart_tooltip/index.ts | 4 +- .../components/job_selector/job_selector.tsx | 280 ++-------------- .../job_selector_badge/{index.js => index.ts} | 0 ...lector_badge.js => job_selector_badge.tsx} | 35 +- .../job_selector/job_selector_flyout.tsx | 289 ++++++++++++++++ .../job_selector_table/job_selector_table.js | 2 +- .../{index.js => index.ts} | 0 ..._badges.js => new_selection_id_badges.tsx} | 32 +- .../datavisualizer/index_based/page.tsx | 4 +- ...s.snap => explorer_swimlane.test.tsx.snap} | 0 .../application/explorer/_explorer.scss | 316 +++++++++--------- .../public/application/explorer/explorer.js | 63 ++-- .../explorer_chart_distribution.js | 12 +- .../explorer_chart_distribution.test.js | 34 +- .../explorer_chart_single_metric.js | 14 +- .../explorer_chart_single_metric.test.js | 34 +- .../explorer_charts_container.js | 109 +++--- .../explorer_charts_container.test.js | 12 +- .../explorer/explorer_constants.ts | 10 +- .../explorer/explorer_dashboard_service.ts | 7 +- ...ane.test.js => explorer_swimlane.test.tsx} | 50 ++- ...orer_swimlane.js => explorer_swimlane.tsx} | 253 ++++++++------ .../application/explorer/explorer_utils.d.ts | 10 +- .../application/explorer/explorer_utils.js | 4 +- .../components/charts/common/settings.ts | 4 +- .../jobs/new_job/pages/new_job/page.tsx | 4 +- .../services/anomaly_detector_service.ts | 58 ++++ .../application/services/explorer_service.ts | 308 +++++++++++++++++ .../application/services/http_service.ts | 104 +++++- .../services/results_service/index.ts | 2 + .../results_service/results_service.d.ts | 11 +- .../timeseries_chart/timeseries_chart.d.ts | 2 + .../timeseries_chart/timeseries_chart.js | 23 +- .../timeseries_chart_annotations.ts | 8 +- .../timeseriesexplorer/timeseriesexplorer.js | 30 +- .../timeseriesexplorer_utils.js | 6 +- .../public/application/util/chart_utils.d.ts | 7 + .../ml/public/application/util/date_utils.ts | 2 +- .../public/application/util/time_buckets.d.ts | 37 +- .../public/application/util/time_buckets.js | 26 +- .../application/util/time_buckets.test.js | 28 +- .../anomaly_swimlane_embeddable.tsx | 115 +++++++ ...omaly_swimlane_embeddable_factory.test.tsx | 50 +++ .../anomaly_swimlane_embeddable_factory.ts | 81 +++++ .../anomaly_swimlane_initializer.tsx | 201 +++++++++++ .../anomaly_swimlane_setup_flyout.tsx | 95 ++++++ .../explorer_swimlane_container.test.tsx | 124 +++++++ .../explorer_swimlane_container.tsx | 122 +++++++ .../embeddables/anomaly_swimlane/index.ts | 8 + .../swimlane_input_resolver.test.ts | 271 +++++++++++++++ .../swimlane_input_resolver.ts | 211 ++++++++++++ x-pack/plugins/ml/public/embeddables/index.ts | 23 ++ x-pack/plugins/ml/public/plugin.ts | 13 + .../ui_actions/edit_swimlane_panel_action.tsx | 65 ++++ x-pack/plugins/ml/public/ui_actions/index.ts | 30 ++ 61 files changed, 3100 insertions(+), 944 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts delete mode 100644 x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js create mode 100644 x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts rename x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/{index.js => index.ts} (100%) rename x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/{job_selector_badge.js => job_selector_badge.tsx} (68%) create mode 100644 x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx rename x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/{index.js => index.ts} (100%) rename x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/{new_selection_id_badges.js => new_selection_id_badges.tsx} (80%) rename x-pack/plugins/ml/public/application/explorer/__snapshots__/{explorer_swimlane.test.js.snap => explorer_swimlane.test.tsx.snap} (100%) rename x-pack/plugins/ml/public/application/explorer/{explorer_swimlane.test.js => explorer_swimlane.test.tsx} (70%) rename x-pack/plugins/ml/public/application/explorer/{explorer_swimlane.js => explorer_swimlane.tsx} (79%) create mode 100644 x-pack/plugins/ml/public/application/services/anomaly_detector_service.ts create mode 100644 x-pack/plugins/ml/public/application/services/explorer_service.ts create mode 100644 x-pack/plugins/ml/public/application/util/chart_utils.d.ts create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.test.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.test.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/index.ts create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.test.ts create mode 100644 x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts create mode 100644 x-pack/plugins/ml/public/embeddables/index.ts create mode 100644 x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx create mode 100644 x-pack/plugins/ml/public/ui_actions/index.ts diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 038f61b3a33b7b..e9d4aff3484b1c 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -13,7 +13,9 @@ "home", "licensing", "usageCollection", - "share" + "share", + "embeddable", + "uiActions" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index 9cc42a4df2f66c..decd1275fe8844 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -4,56 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; -import React, { useRef, FC } from 'react'; +import TooltipTrigger from 'react-popper-tooltip'; import { TooltipValueFormatter } from '@elastic/charts'; -import useObservable from 'react-use/lib/useObservable'; -import { chartTooltip$, ChartTooltipState, ChartTooltipValue } from './chart_tooltip_service'; +import './_index.scss'; -type RefValue = HTMLElement | null; - -function useRefWithCallback(chartTooltipState?: ChartTooltipState) { - const ref = useRef(null); - - return (node: RefValue) => { - ref.current = node; - - if ( - node !== null && - node.parentElement !== null && - chartTooltipState !== undefined && - chartTooltipState.isTooltipVisible - ) { - const parentBounding = node.parentElement.getBoundingClientRect(); - - const { targetPosition, offset } = chartTooltipState; - - const contentWidth = document.body.clientWidth - parentBounding.left; - const tooltipWidth = node.clientWidth; - - let left = targetPosition.left + offset.x - parentBounding.left; - if (left + tooltipWidth > contentWidth) { - // the tooltip is hanging off the side of the page, - // so move it to the other side of the target - left = left - (tooltipWidth + offset.x); - } - - const top = targetPosition.top + offset.y - parentBounding.top; - - if ( - chartTooltipState.tooltipPosition.left !== left || - chartTooltipState.tooltipPosition.top !== top - ) { - // render the tooltip with adjusted position. - chartTooltip$.next({ - ...chartTooltipState, - tooltipPosition: { left, top }, - }); - } - } - }; -} +import { ChildrenArg, TooltipTriggerProps } from 'react-popper-tooltip/dist/types'; +import { ChartTooltipService, ChartTooltipValue, TooltipData } from './chart_tooltip_service'; const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => { if (!headerData) { @@ -63,48 +22,101 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo return formatter ? formatter(headerData) : headerData.label; }; -export const ChartTooltip: FC = () => { - const chartTooltipState = useObservable(chartTooltip$); - const chartTooltipElement = useRefWithCallback(chartTooltipState); +const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => { + const [tooltipData, setData] = useState([]); + const refCallback = useRef(); - if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) { - return

; - } + useEffect(() => { + const subscription = service.tooltipState$.subscribe(tooltipState => { + if (refCallback.current) { + // update trigger + refCallback.current(tooltipState.target); + } + setData(tooltipState.tooltipData); + }); + return () => { + subscription.unsubscribe(); + }; + }, []); + + const triggerCallback = useCallback( + (({ triggerRef }) => { + // obtain the reference to the trigger setter callback + // to update the target based on changes from the service. + refCallback.current = triggerRef; + // actual trigger is resolved by the service, hence don't render + return null; + }) as TooltipTriggerProps['children'], + [] + ); + + const tooltipCallback = useCallback( + (({ tooltipRef, getTooltipProps }) => { + return ( +
+ {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( +
{renderHeader(tooltipData[0])}
+ )} + {tooltipData.length > 1 && ( +
+ {tooltipData + .slice(1) + .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { + const classes = classNames('mlChartTooltip__item', { + /* eslint @typescript-eslint/camelcase:0 */ + echTooltip__rowHighlighted: isHighlighted, + }); + return ( +
+ {label} + {value} +
+ ); + })} +
+ )} +
+ ); + }) as TooltipTriggerProps['tooltip'], + [tooltipData] + ); - const { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState; - const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`; + const isTooltipShown = tooltipData.length > 0; return ( -
- {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( -
- {renderHeader(tooltipData[0], tooltipHeaderFormatter)} -
- )} - {tooltipData.length > 1 && ( -
- {tooltipData - .slice(1) - .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { - const classes = classNames('mlChartTooltip__item', { - /* eslint @typescript-eslint/camelcase:0 */ - echTooltip__rowHighlighted: isHighlighted, - }); - return ( -
- {label} - {value} -
- ); - })} -
- )} -
+ + {triggerCallback} + + ); +}); + +interface MlTooltipComponentProps { + children: (tooltipService: ChartTooltipService) => React.ReactElement; +} + +export const MlTooltipComponent: FC = ({ children }) => { + const service = useMemo(() => new ChartTooltipService(), []); + + return ( + <> + + {children(service)} + ); }; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts deleted file mode 100644 index e6b0b6b4270bda..00000000000000 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts +++ /dev/null @@ -1,42 +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 { BehaviorSubject } from 'rxjs'; - -import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; - -export declare const getChartTooltipDefaultState: () => ChartTooltipState; - -export interface ChartTooltipValue extends TooltipValue { - skipHeader?: boolean; -} - -interface ChartTooltipState { - isTooltipVisible: boolean; - offset: ToolTipOffset; - targetPosition: ClientRect; - tooltipData: ChartTooltipValue[]; - tooltipHeaderFormatter?: TooltipValueFormatter; - tooltipPosition: { left: number; top: number }; -} - -export declare const chartTooltip$: BehaviorSubject; - -interface ToolTipOffset { - x: number; - y: number; -} - -interface MlChartTooltipService { - show: ( - tooltipData: ChartTooltipValue[], - target?: HTMLElement | null, - offset?: ToolTipOffset - ) => void; - hide: () => void; -} - -export declare const mlChartTooltipService: MlChartTooltipService; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js deleted file mode 100644 index 59cf98e5ffd71e..00000000000000 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js +++ /dev/null @@ -1,37 +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 { BehaviorSubject } from 'rxjs'; - -export const getChartTooltipDefaultState = () => ({ - isTooltipVisible: false, - tooltipData: [], - offset: { x: 0, y: 0 }, - targetPosition: { left: 0, top: 0 }, - tooltipPosition: { left: 0, top: 0 }, -}); - -export const chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); - -export const mlChartTooltipService = { - show: (tooltipData, target, offset = { x: 0, y: 0 }) => { - if (typeof target !== 'undefined' && target !== null) { - chartTooltip$.next({ - ...chartTooltip$.getValue(), - isTooltipVisible: true, - offset, - targetPosition: target.getBoundingClientRect(), - tooltipData, - }); - } - }, - hide: () => { - chartTooltip$.next({ - ...getChartTooltipDefaultState(), - isTooltipVisible: false, - }); - }, -}; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts index aa1dbf92b0677a..231854cd264c27 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts @@ -4,18 +4,61 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service'; +import { + ChartTooltipService, + getChartTooltipDefaultState, + TooltipData, +} from './chart_tooltip_service'; -describe('ML - mlChartTooltipService', () => { - it('service API duck typing', () => { - expect(typeof mlChartTooltipService).toBe('object'); - expect(typeof mlChartTooltipService.show).toBe('function'); - expect(typeof mlChartTooltipService.hide).toBe('function'); +describe('ChartTooltipService', () => { + let service: ChartTooltipService; + + beforeEach(() => { + service = new ChartTooltipService(); + }); + + test('should update the tooltip state on show and hide', () => { + const spy = jest.fn(); + + service.tooltipState$.subscribe(spy); + + expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); + + const update = [ + { + label: 'new tooltip', + }, + ] as TooltipData; + const mockEl = document.createElement('div'); + + service.show(update, mockEl); + + expect(spy).toHaveBeenCalledWith({ + isTooltipVisible: true, + tooltipData: update, + offset: { x: 0, y: 0 }, + target: mockEl, + }); + + service.hide(); + + expect(spy).toHaveBeenCalledWith({ + isTooltipVisible: false, + tooltipData: ([] as unknown) as TooltipData, + offset: { x: 0, y: 0 }, + target: null, + }); }); - it('should fail silently when target is not defined', () => { - expect(() => { - mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null); - }).not.toThrow('Call to show() should fail silently.'); + test('update the tooltip state only on a new value', () => { + const spy = jest.fn(); + + service.tooltipState$.subscribe(spy); + + expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); + + service.hide(); + + expect(spy).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts new file mode 100644 index 00000000000000..b524e18102a952 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject, Observable } from 'rxjs'; +import { isEqual } from 'lodash'; +import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; +import { distinctUntilChanged } from 'rxjs/operators'; + +export interface ChartTooltipValue extends TooltipValue { + skipHeader?: boolean; +} + +export interface TooltipHeader { + skipHeader: boolean; +} + +export type TooltipData = ChartTooltipValue[]; + +export interface ChartTooltipState { + isTooltipVisible: boolean; + offset: TooltipOffset; + tooltipData: TooltipData; + tooltipHeaderFormatter?: TooltipValueFormatter; + target: HTMLElement | null; +} + +interface TooltipOffset { + x: number; + y: number; +} + +export const getChartTooltipDefaultState = (): ChartTooltipState => ({ + isTooltipVisible: false, + tooltipData: ([] as unknown) as TooltipData, + offset: { x: 0, y: 0 }, + target: null, +}); + +export class ChartTooltipService { + private chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); + + public tooltipState$: Observable = this.chartTooltip$ + .asObservable() + .pipe(distinctUntilChanged(isEqual)); + + public show( + tooltipData: TooltipData, + target: HTMLElement, + offset: TooltipOffset = { x: 0, y: 0 } + ) { + if (!target) { + throw new Error('target is required for the tooltip positioning'); + } + + this.chartTooltip$.next({ + ...this.chartTooltip$.getValue(), + isTooltipVisible: true, + offset, + tooltipData, + target, + }); + } + + public hide() { + this.chartTooltip$.next({ + ...getChartTooltipDefaultState(), + isTooltipVisible: false, + }); + } +} diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts index 75c65ebaa0f507..ec19fe18bd3241 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { mlChartTooltipService } from './chart_tooltip_service'; -export { ChartTooltip } from './chart_tooltip'; +export { ChartTooltipService } from './chart_tooltip_service'; +export { MlTooltipComponent } from './chart_tooltip'; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx index 381e5e75356c1b..f709c161bef17e 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -4,45 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import PropTypes from 'prop-types'; - -import { - EuiButton, - EuiButtonEmpty, - EuiFlexItem, - EuiFlexGroup, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiSwitch, - EuiTitle, -} from '@elastic/eui'; +import React, { useState, useEffect } from 'react'; +import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useMlKibana } from '../../contexts/kibana'; import { Dictionary } from '../../../../common/types/common'; -import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../../services/ml_api_service'; import { useUrlState } from '../../util/url_state'; // @ts-ignore -import { JobSelectorTable } from './job_selector_table/index'; -// @ts-ignore import { IdBadges } from './id_badges/index'; -// @ts-ignore -import { NewSelectionIdBadges } from './new_selection_id_badges/index'; -import { - getGroupsFromJobs, - getTimeRangeFromSelection, - normalizeTimes, -} from './job_select_service_utils'; +import { BADGE_LIMIT, JobSelectorFlyout, JobSelectorFlyoutProps } from './job_selector_flyout'; +import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; interface GroupObj { groupId: string; jobIds: string[]; } + function mergeSelection( jobIds: string[], groupObjs: GroupObj[], @@ -71,7 +49,7 @@ function mergeSelection( } type GroupsMap = Dictionary; -function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { +export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { const map: GroupsMap = {}; if (selectedGroups.length) { @@ -83,81 +61,38 @@ function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { return map; } -const BADGE_LIMIT = 10; -const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels - interface JobSelectorProps { dateFormatTz: string; singleSelection: boolean; timeseriesOnly: boolean; } +export interface JobSelectionMaps { + jobsMap: Dictionary; + groupsMap: Dictionary; +} + export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) { const [globalState, setGlobalState] = useUrlState('_g'); const selectedJobIds = globalState?.ml?.jobIds ?? []; const selectedGroups = globalState?.ml?.groups ?? []; - const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); - const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} }); + const [maps, setMaps] = useState({ + groupsMap: getInitialGroupsMap(selectedGroups), + jobsMap: {}, + }); const [selectedIds, setSelectedIds] = useState( mergeSelection(selectedJobIds, selectedGroups, singleSelection) ); - const [newSelection, setNewSelection] = useState( - mergeSelection(selectedJobIds, selectedGroups, singleSelection) - ); - const [showAllBadges, setShowAllBadges] = useState(false); const [showAllBarBadges, setShowAllBarBadges] = useState(false); - const [applyTimeRange, setApplyTimeRange] = useState(true); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); - const flyoutEl = useRef<{ flyout: HTMLElement }>(null); - const { - services: { notifications }, - } = useMlKibana(); // Ensure JobSelectionBar gets updated when selection via globalState changes. useEffect(() => { setSelectedIds(mergeSelection(selectedJobIds, selectedGroups, singleSelection)); }, [JSON.stringify([selectedJobIds, selectedGroups])]); - // Ensure current selected ids always show up in flyout - useEffect(() => { - setNewSelection(selectedIds); - }, [isFlyoutVisible]); // eslint-disable-line - - // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. - // Not wrapping it would cause this dependency to change on every render - const handleResize = useCallback(() => { - if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { - // get all cols in flyout table - const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( - 'table thead th' - ); - // get the width of the last col - const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; - const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); - setJobs(normalizedJobs); - const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); - setGroups(updatedGroups); - setGanttBarWidth(derivedWidth); - } - }, [dateFormatTz, jobs]); - - useEffect(() => { - // Ensure ganttBar width gets calculated on resize - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [handleResize]); - - useEffect(() => { - handleResize(); - }, [handleResize, jobs]); - function closeFlyout() { setIsFlyoutVisible(false); } @@ -168,78 +103,26 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function handleJobSelectionClick() { showFlyout(); - - ml.jobs - .jobsWithTimerange(dateFormatTz) - .then(resp => { - const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); - const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); - setJobs(normalizedJobs); - setGroups(groupsWithTimerange); - setMaps({ groupsMap, jobsMap: resp.jobsMap }); - }) - .catch((err: any) => { - console.error('Error fetching jobs with time range', err); // eslint-disable-line - const { toasts } = notifications; - toasts.addDanger({ - title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { - defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', - }), - }); - }); - } - - function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { - setNewSelection(selectionFromTable); } - function applySelection() { - // allNewSelection will be a list of all job ids (including those from groups) selected from the table - const allNewSelection: string[] = []; - const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; - - newSelection.forEach(id => { - if (maps.groupsMap[id] !== undefined) { - // Push all jobs from selected groups into the newSelection list - allNewSelection.push(...maps.groupsMap[id]); - // if it's a group - push group obj to set in global state - groupSelection.push({ groupId: id, jobIds: maps.groupsMap[id] }); - } else { - allNewSelection.push(id); - } - }); - // create a Set to remove duplicate values - const allNewSelectionUnique = Array.from(new Set(allNewSelection)); - + const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = ({ + newSelection, + jobIds, + groups: newGroups, + time, + }) => { setSelectedIds(newSelection); - setNewSelection([]); - - closeFlyout(); - - const time = applyTimeRange - ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) - : undefined; setGlobalState({ ml: { - jobIds: allNewSelectionUnique, - groups: groupSelection, + jobIds, + groups: newGroups, }, ...(time !== undefined ? { time } : {}), }); - } - - function toggleTimerangeSwitch() { - setApplyTimeRange(!applyTimeRange); - } - - function removeId(id: string) { - setNewSelection(newSelection.filter(item => item !== id)); - } - function clearSelection() { - setNewSelection([]); - } + closeFlyout(); + }; function renderJobSelectionBar() { return ( @@ -280,103 +163,16 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function renderFlyout() { if (isFlyoutVisible) { return ( - - - -

- {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { - defaultMessage: 'Job selection', - })} -

-
-
- - - - - setShowAllBadges(!showAllBadges)} - showAllBadges={showAllBadges} - /> - - - - - - {!singleSelection && newSelection.length > 0 && ( - - {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { - defaultMessage: 'Clear all', - })} - - )} - - - - - - - - - - - - - - {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { - defaultMessage: 'Apply', - })} - - - - - {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { - defaultMessage: 'Close', - })} - - - - -
+ ); } } @@ -388,9 +184,3 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
); } - -JobSelector.propTypes = { - selectedJobIds: PropTypes.array, - singleSelection: PropTypes.bool, - timeseriesOnly: PropTypes.bool, -}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx similarity index 68% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx index 4d2ab01e2a0544..b2cae278c0e77a 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx @@ -4,18 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { PropTypes } from 'prop-types'; -import { EuiBadge } from '@elastic/eui'; -import { tabColor } from '../../../../../common/util/group_color_utils'; +import React, { FC } from 'react'; +import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { tabColor } from '../../../../../common/util/group_color_utils'; -export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId }) { +interface JobSelectorBadgeProps { + icon?: boolean; + id: string; + isGroup?: boolean; + numJobs?: number; + removeId?: Function; +} + +export const JobSelectorBadge: FC = ({ + icon, + id, + isGroup = false, + numJobs, + removeId, +}) => { const color = isGroup ? tabColor(id) : 'hollow'; - let props = { color }; + let props = { color } as EuiBadgeProps; let jobCount; - if (icon === true) { + if (icon === true && removeId) { + // @ts-ignore props = { ...props, iconType: 'cross', @@ -37,11 +51,4 @@ export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId {`${id}${jobCount ? jobCount : ''}`} ); -} -JobSelectorBadge.propTypes = { - icon: PropTypes.bool, - id: PropTypes.string.isRequired, - isGroup: PropTypes.bool, - numJobs: PropTypes.number, - removeId: PropTypes.func, }; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx new file mode 100644 index 00000000000000..66aa05d2aaa975 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -0,0 +1,289 @@ +/* + * 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, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; +import { NewSelectionIdBadges } from './new_selection_id_badges'; +// @ts-ignore +import { JobSelectorTable } from './job_selector_table'; +import { + getGroupsFromJobs, + getTimeRangeFromSelection, + normalizeTimes, +} from './job_select_service_utils'; +import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +import { ml } from '../../services/ml_api_service'; +import { useMlKibana } from '../../contexts/kibana'; +import { JobSelectionMaps } from './job_selector'; + +export const BADGE_LIMIT = 10; +export const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels + +export interface JobSelectorFlyoutProps { + dateFormatTz: string; + selectedIds?: string[]; + newSelection?: string[]; + onFlyoutClose: () => void; + onJobsFetched?: (maps: JobSelectionMaps) => void; + onSelectionChange?: (newSelection: string[]) => void; + onSelectionConfirmed: (payload: { + newSelection: string[]; + jobIds: string[]; + groups: Array<{ groupId: string; jobIds: string[] }>; + time: any; + }) => void; + singleSelection: boolean; + timeseriesOnly: boolean; + maps: JobSelectionMaps; + withTimeRangeSelector?: boolean; +} + +export const JobSelectorFlyout: FC = ({ + dateFormatTz, + selectedIds = [], + singleSelection, + timeseriesOnly, + onJobsFetched, + onSelectionChange, + onSelectionConfirmed, + onFlyoutClose, + maps, + withTimeRangeSelector = true, +}) => { + const { + services: { notifications }, + } = useMlKibana(); + + const [newSelection, setNewSelection] = useState(selectedIds); + + const [showAllBadges, setShowAllBadges] = useState(false); + const [applyTimeRange, setApplyTimeRange] = useState(true); + const [jobs, setJobs] = useState([]); + const [groups, setGroups] = useState([]); + const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); + const [jobGroupsMaps, setJobGroupsMaps] = useState(maps); + + const flyoutEl = useRef<{ flyout: HTMLElement }>(null); + + function applySelection() { + // allNewSelection will be a list of all job ids (including those from groups) selected from the table + const allNewSelection: string[] = []; + const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; + + newSelection.forEach(id => { + if (jobGroupsMaps.groupsMap[id] !== undefined) { + // Push all jobs from selected groups into the newSelection list + allNewSelection.push(...jobGroupsMaps.groupsMap[id]); + // if it's a group - push group obj to set in global state + groupSelection.push({ groupId: id, jobIds: jobGroupsMaps.groupsMap[id] }); + } else { + allNewSelection.push(id); + } + }); + // create a Set to remove duplicate values + const allNewSelectionUnique = Array.from(new Set(allNewSelection)); + + const time = applyTimeRange + ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) + : undefined; + + onSelectionConfirmed({ + newSelection: allNewSelectionUnique, + jobIds: allNewSelectionUnique, + groups: groupSelection, + time, + }); + } + + function removeId(id: string) { + setNewSelection(newSelection.filter(item => item !== id)); + } + + function toggleTimerangeSwitch() { + setApplyTimeRange(!applyTimeRange); + } + + function clearSelection() { + setNewSelection([]); + } + + function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { + setNewSelection(selectionFromTable); + } + + // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. + // Not wrapping it would cause this dependency to change on every render + const handleResize = useCallback(() => { + if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { + // get all cols in flyout table + const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( + 'table thead th' + ); + // get the width of the last col + const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; + const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); + setJobs(normalizedJobs); + const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); + setGroups(updatedGroups); + setGanttBarWidth(derivedWidth); + } + }, [dateFormatTz, jobs]); + + // Fetch jobs list on flyout open + useEffect(() => { + fetchJobs(); + }, []); + + async function fetchJobs() { + try { + const resp = await ml.jobs.jobsWithTimerange(dateFormatTz); + const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); + const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); + setJobs(normalizedJobs); + setGroups(groupsWithTimerange); + setJobGroupsMaps({ groupsMap, jobsMap: resp.jobsMap }); + + if (onJobsFetched) { + onJobsFetched({ groupsMap, jobsMap: resp.jobsMap }); + } + } catch (e) { + console.error('Error fetching jobs with time range', e); // eslint-disable-line + const { toasts } = notifications; + toasts.addDanger({ + title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { + defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', + }), + }); + } + } + + useEffect(() => { + // Ensure ganttBar width gets calculated on resize + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [handleResize]); + + useEffect(() => { + handleResize(); + }, [handleResize, jobs]); + + return ( + + + +

+ {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { + defaultMessage: 'Job selection', + })} +

+
+
+ + + + + setShowAllBadges(!showAllBadges)} + showAllBadges={showAllBadges} + /> + + + + + + {!singleSelection && newSelection.length > 0 && ( + + {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { + defaultMessage: 'Clear all', + })} + + )} + + {withTimeRangeSelector && ( + + + + )} + + + + + + + + + + {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { + defaultMessage: 'Apply', + })} + + + + + {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { + defaultMessage: 'Close', + })} + + + + +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js index 64793d15f1e4a4..c55e03776c09d8 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js @@ -224,7 +224,7 @@ export function JobSelectorTable({ {jobs.length === 0 && } {jobs.length !== 0 && singleSelection === true && renderJobsTable()} - {jobs.length !== 0 && singleSelection === undefined && renderTabs()} + {jobs.length !== 0 && !singleSelection && renderTabs()} ); } diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx similarity index 80% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx index 67dce47323889e..4c018e72f3e10c 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx @@ -4,20 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { PropTypes } from 'prop-types'; +import React, { FC, MouseEventHandler } from 'react'; import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; -import { JobSelectorBadge } from '../job_selector_badge'; import { i18n } from '@kbn/i18n'; +import { JobSelectorBadge } from '../job_selector_badge'; +import { JobSelectionMaps } from '../job_selector'; -export function NewSelectionIdBadges({ +interface NewSelectionIdBadgesProps { + limit: number; + maps: JobSelectionMaps; + newSelection: string[]; + onDeleteClick?: Function; + onLinkClick?: MouseEventHandler; + showAllBadges?: boolean; +} + +export const NewSelectionIdBadges: FC = ({ limit, maps, newSelection, onDeleteClick, onLinkClick, showAllBadges, -}) { +}) => { const badges = []; for (let i = 0; i < newSelection.length; i++) { @@ -60,16 +69,5 @@ export function NewSelectionIdBadges({ ); } - return badges; -} -NewSelectionIdBadges.propTypes = { - limit: PropTypes.number, - maps: PropTypes.shape({ - jobsMap: PropTypes.object, - groupsMap: PropTypes.object, - }), - newSelection: PropTypes.array, - onDeleteClick: PropTypes.func, - onLinkClick: PropTypes.func, - showAllBadges: PropTypes.bool, + return <>{badges}; }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 86ffc4a2614b9c..06d89ab782167b 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -41,7 +41,7 @@ import { useMlContext } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; -import { TimeBuckets } from '../../util/time_buckets'; +import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { useUrlState } from '../../util/url_state'; import { FieldRequestConfig, FieldVisConfig } from './common'; import { ActionsPanel } from './components/actions_panel'; @@ -318,7 +318,7 @@ export const Page: FC = () => { // Obtain the interval to use for date histogram aggregations // (such as the document count chart). Aim for 75 bars. - const buckets = new TimeBuckets(); + const buckets = getTimeBucketsFromCache(); const tf = timefilter as any; let earliest: number | undefined; diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap rename to x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index 9fb2f0c3bed944..cfcba081983c2c 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -106,164 +106,6 @@ padding: 0; margin-bottom: $euiSizeS; - div.ml-swimlanes { - margin: 0px 0px 0px 10px; - - div.cells-marker-container { - margin-left: 176px; - height: 22px; - white-space: nowrap; - - // background-color: #CCC; - .sl-cell { - height: 10px; - display: inline-block; - vertical-align: top; - margin-top: 16px; - text-align: center; - visibility: hidden; - cursor: default; - - i { - color: $euiColorDarkShade; - } - } - - .sl-cell-hover { - visibility: visible; - - i { - display: block; - margin-top: -6px; - } - } - - .sl-cell-active-hover { - visibility: visible; - - .floating-time-label { - display: inline-block; - } - } - } - - div.lane { - height: 30px; - border-bottom: 0px; - border-radius: 2px; - margin-top: -1px; - white-space: nowrap; - - div.lane-label { - display: inline-block; - font-size: 13px; - height: 30px; - text-align: right; - vertical-align: middle; - border-radius: 2px; - padding-right: 5px; - margin-right: 5px; - border: 1px solid transparent; - overflow: hidden; - text-overflow: ellipsis; - } - - div.lane-label.lane-label-masked { - opacity: 0.3; - } - - div.cells-container { - border: $euiBorderThin; - border-right: 0px; - display: inline-block; - height: 30px; - vertical-align: middle; - background-color: $euiColorEmptyShade; - - .sl-cell { - color: $euiColorEmptyShade; - cursor: default; - display: inline-block; - height: 29px; - border-right: $euiBorderThin; - vertical-align: top; - position: relative; - - .sl-cell-inner, - .sl-cell-inner-dragselect { - height: 26px; - margin: 1px; - border-radius: 2px; - text-align: center; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.2; - } - - .sl-cell-inner.sl-cell-inner-selected, - .sl-cell-inner-dragselect.sl-cell-inner-selected { - border: 2px solid $euiColorDarkShade; - } - - .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, - .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { - border: 2px solid $euiColorFullShade; - opacity: 0.4; - } - } - - .sl-cell:hover { - .sl-cell-inner { - opacity: 0.8; - cursor: pointer; - } - } - - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border: 2px solid $euiColorDarkShade; - border-radius: 2px; - opacity: 1; - } - } - - } - } - - div.lane:last-child { - div.cells-container { - .sl-cell { - border-bottom: $euiBorderThin; - } - } - } - - .time-tick-labels { - height: 25px; - margin-top: $euiSizeXS / 2; - margin-left: 175px; - - /* hide d3's domain line */ - path.domain { - display: none; - } - - /* hide d3's tick line */ - g.tick line { - display: none; - } - - /* override d3's default tick styles */ - g.tick text { - font-size: 11px; - fill: $euiColorMediumShade; - } - } - } - line.gridLine { stroke: $euiBorderColor; fill: none; @@ -328,3 +170,161 @@ } } } + +.ml-swimlanes { + margin: 0px 0px 0px 10px; + + div.cells-marker-container { + margin-left: 176px; + height: 22px; + white-space: nowrap; + + // background-color: #CCC; + .sl-cell { + height: 10px; + display: inline-block; + vertical-align: top; + margin-top: 16px; + text-align: center; + visibility: hidden; + cursor: default; + + i { + color: $euiColorDarkShade; + } + } + + .sl-cell-hover { + visibility: visible; + + i { + display: block; + margin-top: -6px; + } + } + + .sl-cell-active-hover { + visibility: visible; + + .floating-time-label { + display: inline-block; + } + } + } + + div.lane { + height: 30px; + border-bottom: 0px; + border-radius: 2px; + margin-top: -1px; + white-space: nowrap; + + div.lane-label { + display: inline-block; + font-size: 13px; + height: 30px; + text-align: right; + vertical-align: middle; + border-radius: 2px; + padding-right: 5px; + margin-right: 5px; + border: 1px solid transparent; + overflow: hidden; + text-overflow: ellipsis; + } + + div.lane-label.lane-label-masked { + opacity: 0.3; + } + + div.cells-container { + border: $euiBorderThin; + border-right: 0px; + display: inline-block; + height: 30px; + vertical-align: middle; + background-color: $euiColorEmptyShade; + + .sl-cell { + color: $euiColorEmptyShade; + cursor: default; + display: inline-block; + height: 29px; + border-right: $euiBorderThin; + vertical-align: top; + position: relative; + + .sl-cell-inner, + .sl-cell-inner-dragselect { + height: 26px; + margin: 1px; + border-radius: 2px; + text-align: center; + } + + .sl-cell-inner.sl-cell-inner-masked { + opacity: 0.2; + } + + .sl-cell-inner.sl-cell-inner-selected, + .sl-cell-inner-dragselect.sl-cell-inner-selected { + border: 2px solid $euiColorDarkShade; + } + + .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, + .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { + border: 2px solid $euiColorFullShade; + opacity: 0.4; + } + } + + .sl-cell:hover { + .sl-cell-inner { + opacity: 0.8; + cursor: pointer; + } + } + + .sl-cell.ds-selected { + + .sl-cell-inner, + .sl-cell-inner-dragselect { + border: 2px solid $euiColorDarkShade; + border-radius: 2px; + opacity: 1; + } + } + + } + } + + div.lane:last-child { + div.cells-container { + .sl-cell { + border-bottom: $euiBorderThin; + } + } + } + + .time-tick-labels { + height: 25px; + margin-top: $euiSizeXS / 2; + margin-left: 175px; + + /* hide d3's domain line */ + path.domain { + display: none; + } + + /* hide d3's tick line */ + g.tick line { + display: none; + } + + /* override d3's default tick styles */ + g.tick text { + font-size: 11px; + fill: $euiColorMediumShade; + } + } +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index d61d56d07b6446..86d16776b68e2a 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -36,9 +36,8 @@ import { ExplorerNoJobsFound, ExplorerNoResultsFound, } from './components'; -import { ChartTooltip } from '../components/chart_tooltip'; import { ExplorerSwimlane } from './explorer_swimlane'; -import { TimeBuckets } from '../util/time_buckets'; +import { getTimeBucketsFromCache } from '../util/time_buckets'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, @@ -81,6 +80,7 @@ import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; +import { MlTooltipComponent } from '../components/chart_tooltip'; function mapSwimlaneOptionsToEuiOptions(options) { return options.map(option => ({ @@ -179,6 +179,8 @@ export class Explorer extends React.Component { // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', this.resizeHandler); + + this.timeBuckets = getTimeBucketsFromCache(); } componentWillUnmount() { @@ -358,9 +360,6 @@ export class Explorer extends React.Component { return (
- {/* Make sure ChartTooltip is inside wrapping div with 0px left/right padding so positioning can be inferred correctly. */} - - {noInfluencersConfigured === false && influencers !== undefined && (
{showOverallSwimlane && ( - + + {tooltipService => ( + + )} + )}
@@ -494,17 +498,22 @@ export class Explorer extends React.Component { onMouseLeave={this.onSwimlaneLeaveHandler} data-test-subj="mlAnomalyExplorerSwimlaneViewBy" > - + + {tooltipService => ( + + )} +
)} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 5fc1160093a491..03426869b0ccfc 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -29,9 +29,8 @@ import { removeLabelOverlap, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { TimeBuckets } from '../../util/time_buckets'; +import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; -import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -50,6 +49,7 @@ export class ExplorerChartDistribution extends React.Component { static propTypes = { seriesConfig: PropTypes.object, severity: PropTypes.number, + tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -61,7 +61,7 @@ export class ExplorerChartDistribution extends React.Component { } renderChart() { - const { tooManyBuckets } = this.props; + const { tooManyBuckets, tooltipService } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -259,7 +259,7 @@ export class ExplorerChartDistribution extends React.Component { function drawRareChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = new TimeBuckets(); + const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -397,7 +397,7 @@ export class ExplorerChartDistribution extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => mlChartTooltipService.hide()); + .on('mouseout', () => tooltipService.hide()); // Update all dots to new positions. dots @@ -550,7 +550,7 @@ export class ExplorerChartDistribution extends React.Component { }); } - mlChartTooltipService.show(tooltipData, circle, { + tooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index 71d777db5b2ec6..06fd82204c1e1a 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -10,11 +10,13 @@ import seriesConfig from './__mocks__/mock_series_config_rare.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - TimeBuckets: function() { - this.setBounds = jest.fn(); - this.setInterval = jest.fn(); - this.getScaledDateFormat = jest.fn(); - }, + getTimeBucketsFromCache: jest.fn(() => { + return { + setBounds: jest.fn(), + setInterval: jest.fn(), + getScaledDateFormat: jest.fn(), + }; + }), })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -43,8 +45,16 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -59,10 +69,16 @@ describe('ExplorerChart', () => { loading: true, }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( ); @@ -83,12 +99,18 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index dd9479be931a79..82041af39ca15e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -38,10 +38,9 @@ import { showMultiBucketAnomalyTooltip, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { TimeBuckets } from '../../util/time_buckets'; +import { getTimeBucketsFromCache } from '../../util/time_buckets'; import { mlEscape } from '../../util/string_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; -import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { i18n } from '@kbn/i18n'; @@ -53,6 +52,7 @@ export class ExplorerChartSingleMetric extends React.Component { tooManyBuckets: PropTypes.bool, seriesConfig: PropTypes.object, severity: PropTypes.number.isRequired, + tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -64,7 +64,7 @@ export class ExplorerChartSingleMetric extends React.Component { } renderChart() { - const { tooManyBuckets } = this.props; + const { tooManyBuckets, tooltipService } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -191,7 +191,7 @@ export class ExplorerChartSingleMetric extends React.Component { function drawLineChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = new TimeBuckets(); + const timeBuckets = getTimeBucketsFromCache(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -309,7 +309,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => mlChartTooltipService.hide()); + .on('mouseout', () => tooltipService.hide()); const isAnomalyVisible = d => _.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity; @@ -354,7 +354,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => mlChartTooltipService.hide()); + .on('mouseout', () => tooltipService.hide()); // Add rectangular markers for any scheduled events. const scheduledEventMarkers = lineChartGroup @@ -503,7 +503,7 @@ export class ExplorerChartSingleMetric extends React.Component { }); } - mlChartTooltipService.show(tooltipData, circle, { + tooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index ca3e52308a9366..54f541ceb7c3da 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -10,11 +10,13 @@ import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - TimeBuckets: function() { - this.setBounds = jest.fn(); - this.setInterval = jest.fn(); - this.getScaledDateFormat = jest.fn(); - }, + getTimeBucketsFromCache: jest.fn(() => { + return { + setBounds: jest.fn(), + setInterval: jest.fn(), + getScaledDateFormat: jest.fn(), + }; + }), })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -43,8 +45,16 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -59,10 +69,16 @@ describe('ExplorerChart', () => { loading: true, }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + const wrapper = mountWithIntl( ); @@ -83,12 +99,18 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; + const mockTooltipService = { + show: jest.fn(), + hide: jest.fn(), + }; + // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 99de38c1e0a84a..5b95931d31ab61 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import $ from 'jquery'; - import React from 'react'; import { @@ -29,6 +27,7 @@ import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { MlTooltipComponent } from '../../components/chart_tooltip'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: @@ -121,19 +120,29 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) chartType === CHART_TYPE.POPULATION_DISTRIBUTION ) { return ( - + + {tooltipService => ( + + )} + ); } return ( - + + {tooltipService => ( + + )} + ); })()} @@ -141,48 +150,36 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) } // Flex layout wrapper for all explorer charts -export class ExplorerChartsContainer extends React.Component { - componentDidMount() { - // Create a div for the tooltip. - $('.ml-explorer-charts-tooltip').remove(); - $('body').append( - '