diff --git a/package.json b/package.json index 1395050263fd6..aa7c0968c4920 100644 --- a/package.json +++ b/package.json @@ -77,28 +77,30 @@ "url": "https://github.com/elastic/kibana.git" }, "resolutions": { - "**/@types/node": ">=10.17.17 <10.20.0", - "**/@types/react": "^16.9.36", - "**/@types/hapi": "^17.0.18", "**/@types/angular": "^1.6.56", - "**/@types/hoist-non-react-statics": "^3.3.1", "**/@types/chai": "^4.2.11", + "**/@types/hapi": "^17.0.18", + "**/@types/hoist-non-react-statics": "^3.3.1", + "**/@types/node": ">=10.17.17 <10.20.0", + "**/@types/react": "^16.9.36", + "**/cross-fetch/node-fetch": "^2.6.1", "**/cypress/@types/lodash": "^4.14.159", "**/cypress/lodash": "^4.17.20", - "**/typescript": "4.0.2", + "**/deepmerge": "^4.2.2", + "**/fast-deep-equal": "^3.1.1", "**/graphql-toolkit/lodash": "^4.17.15", "**/hoist-non-react-statics": "^3.3.2", + "**/image-diff/gm/debug": "^2.6.9", + "**/isomorphic-fetch/node-fetch": "^2.6.1", "**/isomorphic-git/**/base64-js": "^1.2.1", "**/istanbul-instrumenter-loader/schema-utils": "1.0.0", - "**/image-diff/gm/debug": "^2.6.9", "**/load-grunt-config/lodash": "^4.17.20", "**/node-jose/node-forge": "^0.10.0", - "**/react-dom": "^16.12.0", "**/react": "^16.12.0", + "**/react-dom": "^16.12.0", "**/react-test-renderer": "^16.12.0", "**/request": "^2.88.2", - "**/deepmerge": "^4.2.2", - "**/fast-deep-equal": "^3.1.1" + "**/typescript": "4.0.2" }, "workspaces": { "packages": [ @@ -194,7 +196,7 @@ "moment": "^2.24.0", "moment-timezone": "^0.5.27", "mustache": "2.3.2", - "node-fetch": "1.7.3", + "node-fetch": "2.6.1", "node-forge": "^0.10.0", "opn": "^5.5.0", "oppsy": "^2.0.0", diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index dabf11fdd0b66..52ef3fe05e751 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -14,7 +14,7 @@ "execa": "^4.0.2", "getopts": "^2.2.4", "glob": "^7.1.2", - "node-fetch": "^2.6.0", + "node-fetch": "^2.6.1", "simple-git": "^1.91.0", "tar-fs": "^2.1.0", "tree-kill": "^1.2.2", diff --git a/src/core/server/metrics/metrics_service.test.ts b/src/core/server/metrics/metrics_service.test.ts index 269931d0e33ad..384a56c8dba94 100644 --- a/src/core/server/metrics/metrics_service.test.ts +++ b/src/core/server/metrics/metrics_service.test.ts @@ -106,6 +106,25 @@ describe('MetricsService', () => { `"#setup() needs to be run first"` ); }); + + it('emits the last value on each getOpsMetrics$ call', async () => { + const firstMetrics = { metric: 'first' }; + const secondMetrics = { metric: 'second' }; + mockOpsCollector.collect + .mockResolvedValueOnce(firstMetrics) + .mockResolvedValueOnce(secondMetrics); + + await metricsService.setup({ http: httpMock }); + const { getOpsMetrics$ } = await metricsService.start(); + + const firstEmission = getOpsMetrics$().pipe(take(1)).toPromise(); + jest.advanceTimersByTime(testInterval); + expect(await firstEmission).toEqual({ metric: 'first' }); + + const secondEmission = getOpsMetrics$().pipe(take(1)).toPromise(); + jest.advanceTimersByTime(testInterval); + expect(await secondEmission).toEqual({ metric: 'second' }); + }); }); describe('#stop', () => { diff --git a/src/core/server/metrics/metrics_service.ts b/src/core/server/metrics/metrics_service.ts index d4696b3aa9aaf..ab58a75d49a98 100644 --- a/src/core/server/metrics/metrics_service.ts +++ b/src/core/server/metrics/metrics_service.ts @@ -37,7 +37,7 @@ export class MetricsService private readonly logger: Logger; private metricsCollector?: OpsMetricsCollector; private collectInterval?: NodeJS.Timeout; - private metrics$ = new ReplaySubject(); + private metrics$ = new ReplaySubject(1); private service?: InternalMetricsServiceSetup; constructor(private readonly coreContext: CoreContext) { diff --git a/src/plugins/discover/public/application/_discover.scss b/src/plugins/discover/public/application/_discover.scss index 69df2a75b8d75..bc704439d161b 100644 --- a/src/plugins/discover/public/application/_discover.scss +++ b/src/plugins/discover/public/application/_discover.scss @@ -5,6 +5,11 @@ overflow: hidden; } +.dscAppContainer { + > * { + position: relative; + } +} discover-app { flex-grow: 1; } @@ -17,9 +22,12 @@ discover-app { // SASSTODO: replace the z-index value with a variable .dscWrapper { + padding-left: $euiSizeXL; padding-right: $euiSizeS; - padding-left: 21px; z-index: 1; + @include euiBreakpoint('xs', 's', 'm') { + padding-left: $euiSizeS; + } } @include euiPanel('.dscWrapper__content'); @@ -104,14 +112,51 @@ discover-app { top: $euiSizeXS; } -[fixed-scroll] { +.dscTableFixedScroll { overflow-x: auto; padding-bottom: 0; - + .fixed-scroll-scroller { + + .dscTableFixedScroll__scroller { position: fixed; bottom: 0; overflow-x: auto; overflow-y: hidden; } } + +.dscCollapsibleSidebar { + position: relative; + z-index: $euiZLevel1; + + .dscCollapsibleSidebar__collapseButton { + position: absolute; + top: 0; + right: -$euiSizeXL + 4; + cursor: pointer; + z-index: -1; + min-height: $euiSizeM; + min-width: $euiSizeM; + padding: $euiSizeXS * .5; + } + + &.closed { + width: 0 !important; + border-right-width: 0; + border-left-width: 0; + .dscCollapsibleSidebar__collapseButton { + right: -$euiSizeL + 4; + } + } +} + +@include euiBreakpoint('xs', 's', 'm') { + .dscCollapsibleSidebar { + &.closed { + display: none; + } + + .dscCollapsibleSidebar__collapseButton { + display: none; + } + } +} diff --git a/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap b/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap index e7aea41e2d08e..e69e10e29e801 100644 --- a/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap +++ b/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap @@ -167,132 +167,6 @@ Array [ ] `; -exports[`DiscoverNoResults props shardFailures doesn't render failures list when there are no failures 1`] = ` -Array [ -
, -
-
-
-
- -
-
-
, -] -`; - -exports[`DiscoverNoResults props shardFailures renders failures list when there are failures 1`] = ` -Array [ -
, -
-
-
-
- -
-
-
-

- Address shard failures -

-

- The following shard failures occurred: -

-
-
- - Index ‘A’ - - , shard ‘1’ -
-
-
-
-              
-                {"reason":"Awful error"}
-              
-            
-
-
-
-
-
- - Index ‘B’ - - , shard ‘2’ -
-
-
-
-              
-                {"reason":"Bad error"}
-              
-            
-
-
-
-
-
, -] -`; - exports[`DiscoverNoResults props timeFieldName renders time range feedback 1`] = ` Array [
* { - visibility: hidden; - } - - .kbnCollapsibleSidebar__collapseButton { - visibility: visible; - - .chevron-cont:before { - content: "\F138"; - } - } - } -} - -@include euiBreakpoint('xs', 's', 'm') { - .collapsible-sidebar { - &.closed { - display: none; - } - - .kbnCollapsibleSidebar__collapseButton { - display: none; - } - } -} diff --git a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_depth.scss b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_depth.scss deleted file mode 100644 index 4bc59001f9931..0000000000000 --- a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_depth.scss +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 1. The local nav contains tooltips which should pop over the filter bar. - * 2. The filter and local nav components should always appear above the dashboard grid items. - * 3. The filter and local nav components should always appear above the discover content. - * 4. The sidebar collapser button should appear above the main Discover content but below the top elements. - * 5. Dragged panels in dashboard should always appear above other panels. - */ -$kbnFilterBarDepth: 4; /* 1 */ -$kbnLocalNavDepth: 5; /* 1 */ -$kbnDashboardGridDepth: 1; /* 2 */ -$kbnDashboardDraggingGridDepth: 2; /* 5 */ -$kbnDiscoverWrapperDepth: 1; /* 3 */ -$kbnDiscoverSidebarDepth: 2; /* 4 */ diff --git a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_index.scss b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_index.scss deleted file mode 100644 index 1409920d11aa7..0000000000000 --- a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import 'depth'; -@import 'collapsible_sidebar'; diff --git a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/collapsible_sidebar.ts b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/collapsible_sidebar.ts deleted file mode 100644 index 16fbb0af9f3fd..0000000000000 --- a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/collapsible_sidebar.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import $ from 'jquery'; -import { IScope } from 'angular'; - -interface LazyScope extends IScope { - [key: string]: any; -} - -export function CollapsibleSidebarProvider() { - // simply a list of all of all of angulars .col-md-* classes except 12 - const listOfWidthClasses = _.times(11, function (i) { - return 'col-md-' + i; - }); - - return { - restrict: 'C', - link: ($scope: LazyScope, $elem: any) => { - let isCollapsed = false; - const $collapser = $( - `` - ); - // If the collapsable element has an id, also set aria-controls - if ($elem.attr('id')) { - $collapser.attr('aria-controls', $elem.attr('id')); - } - const $icon = $(''); - $collapser.append($icon); - const $siblings = $elem.siblings(); - - const siblingsClass = listOfWidthClasses.reduce((prev: string, className: string) => { - if (prev) return prev; - return $siblings.hasClass(className) && className; - }, ''); - - // If there is are only two elements we can assume the other one will take 100% of the width. - const hasSingleSibling = $siblings.length === 1 && siblingsClass; - - $collapser.on('click', function () { - if (isCollapsed) { - isCollapsed = false; - $elem.removeClass('closed'); - $icon.addClass('fa-chevron-circle-left'); - $icon.removeClass('fa-chevron-circle-right'); - $collapser.attr('aria-expanded', 'true'); - } else { - isCollapsed = true; - $elem.addClass('closed'); - $icon.removeClass('fa-chevron-circle-left'); - $icon.addClass('fa-chevron-circle-right'); - $collapser.attr('aria-expanded', 'false'); - } - - if (hasSingleSibling) { - $siblings.toggleClass(siblingsClass + ' col-md-12'); - } - - if ($scope.toggleSidebar) $scope.toggleSidebar(); - }); - - $collapser.appendTo($elem); - }, - }; -} diff --git a/src/plugins/discover/public/application/angular/directives/debounce/debounce.js b/src/plugins/discover/public/application/angular/directives/debounce/debounce.js index 586e8ed4fab59..8ce2b042c0efe 100644 --- a/src/plugins/discover/public/application/angular/directives/debounce/debounce.js +++ b/src/plugins/discover/public/application/angular/directives/debounce/debounce.js @@ -21,7 +21,7 @@ import _ from 'lodash'; // Debounce service, angularized version of lodash debounce // borrowed heavily from https://github.com/shahata/angular-debounce -export function DebounceProviderTimeout($timeout) { +export function createDebounceProviderTimeout($timeout) { return function (func, wait, options) { let timeout; let args; @@ -66,7 +66,3 @@ export function DebounceProviderTimeout($timeout) { return debounce; }; } - -export function DebounceProvider(debounce) { - return debounce; -} diff --git a/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts b/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts index ccdee153002e4..0cdc214cf97f5 100644 --- a/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts +++ b/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts @@ -24,7 +24,7 @@ import 'angular-sanitize'; import 'angular-route'; // @ts-ignore -import { DebounceProvider } from './index'; +import { createDebounceProviderTimeout } from './debounce'; import { coreMock } from '../../../../../../../core/public/mocks'; import { initializeInnerAngularModule } from '../../../../get_inner_angular'; import { navigationPluginMock } from '../../../../../../navigation/public/mocks'; @@ -33,7 +33,6 @@ import { initAngularBootstrap } from '../../../../../../kibana_legacy/public'; describe('debounce service', function () { let debounce: (fn: () => void, timeout: number, options?: any) => any; - let debounceFromProvider: (fn: () => void, timeout: number, options?: any) => any; let $timeout: ITimeoutService; let spy: SinonSpy; @@ -51,22 +50,17 @@ describe('debounce service', function () { angular.mock.module('app/discover'); - angular.mock.inject( - ($injector: auto.IInjectorService, _$timeout_: ITimeoutService, Private: any) => { - $timeout = _$timeout_; + angular.mock.inject(($injector: auto.IInjectorService, _$timeout_: ITimeoutService) => { + $timeout = _$timeout_; - debounce = $injector.get('debounce'); - debounceFromProvider = Private(DebounceProvider); - } - ); + debounce = createDebounceProviderTimeout($timeout); + }); }); it('should have a cancel method', function () { const bouncer = debounce(() => {}, 100); - const bouncerFromProvider = debounceFromProvider(() => {}, 100); expect(bouncer).toHaveProperty('cancel'); - expect(bouncerFromProvider).toHaveProperty('cancel'); }); describe('delayed execution', function () { @@ -77,7 +71,6 @@ describe('debounce service', function () { it('should delay execution', function () { const bouncer = debounce(spy, 100); - const bouncerFromProvider = debounceFromProvider(spy, 100); bouncer(); sinon.assert.notCalled(spy); @@ -85,16 +78,10 @@ describe('debounce service', function () { sinon.assert.calledOnce(spy); spy.resetHistory(); - - bouncerFromProvider(); - sinon.assert.notCalled(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); }); it('should fire on leading edge', function () { const bouncer = debounce(spy, 100, { leading: true }); - const bouncerFromProvider = debounceFromProvider(spy, 100, { leading: true }); bouncer(); sinon.assert.calledOnce(spy); @@ -102,19 +89,10 @@ describe('debounce service', function () { sinon.assert.calledTwice(spy); spy.resetHistory(); - - bouncerFromProvider(); - sinon.assert.calledOnce(spy); - $timeout.flush(); - sinon.assert.calledTwice(spy); }); it('should only fire on leading edge', function () { const bouncer = debounce(spy, 100, { leading: true, trailing: false }); - const bouncerFromProvider = debounceFromProvider(spy, 100, { - leading: true, - trailing: false, - }); bouncer(); sinon.assert.calledOnce(spy); @@ -122,17 +100,11 @@ describe('debounce service', function () { sinon.assert.calledOnce(spy); spy.resetHistory(); - - bouncerFromProvider(); - sinon.assert.calledOnce(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); }); it('should reset delayed execution', function () { const cancelSpy = sinon.spy($timeout, 'cancel'); const bouncer = debounce(spy, 100); - const bouncerFromProvider = debounceFromProvider(spy, 100); bouncer(); sandbox.clock.tick(1); @@ -145,15 +117,6 @@ describe('debounce service', function () { spy.resetHistory(); cancelSpy.resetHistory(); - - bouncerFromProvider(); - sandbox.clock.tick(1); - - bouncerFromProvider(); - sinon.assert.notCalled(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); - sinon.assert.calledOnce(cancelSpy); }); }); @@ -161,7 +124,6 @@ describe('debounce service', function () { it('should cancel the $timeout', function () { const cancelSpy = sinon.spy($timeout, 'cancel'); const bouncer = debounce(spy, 100); - const bouncerFromProvider = debounceFromProvider(spy, 100); bouncer(); bouncer.cancel(); @@ -170,12 +132,6 @@ describe('debounce service', function () { $timeout.verifyNoPendingTasks(); cancelSpy.resetHistory(); - - bouncerFromProvider(); - bouncerFromProvider.cancel(); - sinon.assert.calledOnce(cancelSpy); - // throws if pending timeouts - $timeout.verifyNoPendingTasks(); }); }); }); diff --git a/src/plugins/discover/public/application/angular/directives/debounce/index.js b/src/plugins/discover/public/application/angular/directives/debounce/index.js index 35b8339263626..3c51895f19828 100644 --- a/src/plugins/discover/public/application/angular/directives/debounce/index.js +++ b/src/plugins/discover/public/application/angular/directives/debounce/index.js @@ -17,6 +17,4 @@ * under the License. */ -import './debounce'; - -export { DebounceProvider } from './debounce'; +export { createDebounceProviderTimeout } from './debounce'; diff --git a/src/plugins/discover/public/application/angular/directives/fixed_scroll.js b/src/plugins/discover/public/application/angular/directives/fixed_scroll.js index 182b4aeca9a23..e2d5f10a0faf7 100644 --- a/src/plugins/discover/public/application/angular/directives/fixed_scroll.js +++ b/src/plugins/discover/public/application/angular/directives/fixed_scroll.js @@ -19,7 +19,7 @@ import $ from 'jquery'; import _ from 'lodash'; -import { DebounceProvider } from './debounce'; +import { createDebounceProviderTimeout } from './debounce'; const SCROLLER_HEIGHT = 20; @@ -28,124 +28,128 @@ const SCROLLER_HEIGHT = 20; * to the target element's real scrollbar. This is useful when the target element's horizontal scrollbar * might be waaaay down the page, like the doc table on Discover. */ -export function FixedScrollProvider(Private) { - const debounce = Private(DebounceProvider); - +export function FixedScrollProvider($timeout) { return { restrict: 'A', link: function ($scope, $el) { - let $window = $(window); - let $scroller = $('
').height(SCROLLER_HEIGHT); - - /** - * Remove the listeners bound in listen() - * @type {function} - */ - let unlisten = _.noop; - - /** - * Listen for scroll events on the $scroller and the $el, sets unlisten() - * - * unlisten must be called before calling or listen() will throw an Error - * - * Since the browser emits "scroll" events after setting scrollLeft - * the listeners also prevent tug-of-war - * - * @throws {Error} If unlisten was not called first - * @return {undefined} - */ - function listen() { - if (unlisten !== _.noop) { - throw new Error( - 'fixedScroll listeners were not cleaned up properly before re-listening!' - ); - } - - let blockTo; - function bind($from, $to) { - function handler() { - if (blockTo === $to) return (blockTo = null); - $to.scrollLeft((blockTo = $from).scrollLeft()); - } - - $from.on('scroll', handler); - return function () { - $from.off('scroll', handler); - }; - } - - unlisten = _.flow(bind($el, $scroller), bind($scroller, $el), function () { - unlisten = _.noop; - }); - } - - /** - * Revert DOM changes and event listeners - * @return {undefined} - */ - function cleanUp() { - unlisten(); - $scroller.detach(); - $el.css('padding-bottom', 0); - } - - /** - * Modify the DOM and attach event listeners based on need. - * Is called many times to re-setup, must be idempotent - * @return {undefined} - */ - function setup() { - cleanUp(); - - const containerWidth = $el.width(); - const contentWidth = $el.prop('scrollWidth'); - const containerHorizOverflow = contentWidth - containerWidth; - - const elTop = $el.offset().top - $window.scrollTop(); - const elBottom = elTop + $el.height(); - const windowVertOverflow = elBottom - $window.height(); - - const requireScroller = containerHorizOverflow > 0 && windowVertOverflow > 0; - if (!requireScroller) return; - - // push the content away from the scroller - $el.css('padding-bottom', SCROLLER_HEIGHT); - - // fill the scroller with a dummy element that mimics the content - $scroller - .width(containerWidth) - .html($('
').css({ width: contentWidth, height: SCROLLER_HEIGHT })) - .insertAfter($el); + return createFixedScroll($scope, $timeout)($el); + }, + }; +} - // listen for scroll events - listen(); +export function createFixedScroll($scope, $timeout) { + const debounce = createDebounceProviderTimeout($timeout); + return function (el) { + const $el = typeof el.css === 'function' ? el : $(el); + let $window = $(window); + let $scroller = $('
').height(SCROLLER_HEIGHT); + + /** + * Remove the listeners bound in listen() + * @type {function} + */ + let unlisten = _.noop; + + /** + * Listen for scroll events on the $scroller and the $el, sets unlisten() + * + * unlisten must be called before calling or listen() will throw an Error + * + * Since the browser emits "scroll" events after setting scrollLeft + * the listeners also prevent tug-of-war + * + * @throws {Error} If unlisten was not called first + * @return {undefined} + */ + function listen() { + if (unlisten !== _.noop) { + throw new Error('fixedScroll listeners were not cleaned up properly before re-listening!'); } - let width; - let scrollWidth; - function checkWidth() { - const newScrollWidth = $el.prop('scrollWidth'); - const newWidth = $el.width(); - - if (scrollWidth !== newScrollWidth || width !== newWidth) { - $scope.$apply(setup); - - scrollWidth = newScrollWidth; - width = newWidth; + let blockTo; + function bind($from, $to) { + function handler() { + if (blockTo === $to) return (blockTo = null); + $to.scrollLeft((blockTo = $from).scrollLeft()); } - } - const debouncedCheckWidth = debounce(checkWidth, 100, { - invokeApply: false, - }); - $scope.$watch(debouncedCheckWidth); + $from.on('scroll', handler); + return function () { + $from.off('scroll', handler); + }; + } - // cleanup when the scope is destroyed - $scope.$on('$destroy', function () { - cleanUp(); - debouncedCheckWidth.cancel(); - $scroller = $window = null; + unlisten = _.flow(bind($el, $scroller), bind($scroller, $el), function () { + unlisten = _.noop; }); - }, + } + + /** + * Revert DOM changes and event listeners + * @return {undefined} + */ + function cleanUp() { + unlisten(); + $scroller.detach(); + $el.css('padding-bottom', 0); + } + + /** + * Modify the DOM and attach event listeners based on need. + * Is called many times to re-setup, must be idempotent + * @return {undefined} + */ + function setup() { + cleanUp(); + + const containerWidth = $el.width(); + const contentWidth = $el.prop('scrollWidth'); + const containerHorizOverflow = contentWidth - containerWidth; + + const elTop = $el.offset().top - $window.scrollTop(); + const elBottom = elTop + $el.height(); + const windowVertOverflow = elBottom - $window.height(); + + const requireScroller = containerHorizOverflow > 0 && windowVertOverflow > 0; + if (!requireScroller) return; + + // push the content away from the scroller + $el.css('padding-bottom', SCROLLER_HEIGHT); + + // fill the scroller with a dummy element that mimics the content + $scroller + .width(containerWidth) + .html($('
').css({ width: contentWidth, height: SCROLLER_HEIGHT })) + .insertAfter($el); + + // listen for scroll events + listen(); + } + + let width; + let scrollWidth; + function checkWidth() { + const newScrollWidth = $el.prop('scrollWidth'); + const newWidth = $el.width(); + + if (scrollWidth !== newScrollWidth || width !== newWidth) { + $scope.$apply(setup); + + scrollWidth = newScrollWidth; + width = newWidth; + } + } + + const debouncedCheckWidth = debounce(checkWidth, 100, { + invokeApply: false, + }); + $scope.$watch(debouncedCheckWidth); + + function destroy() { + cleanUp(); + debouncedCheckWidth.cancel(); + $scroller = $window = null; + } + return destroy; }; } diff --git a/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js b/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js index 65255d6c0c4a4..e44bb45cf2431 100644 --- a/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js +++ b/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js @@ -23,17 +23,12 @@ import $ from 'jquery'; import sinon from 'sinon'; -import { PrivateProvider, initAngularBootstrap } from '../../../../../kibana_legacy/public'; +import { initAngularBootstrap } from '../../../../../kibana_legacy/public'; import { FixedScrollProvider } from './fixed_scroll'; -import { DebounceProviderTimeout } from './debounce/debounce'; const testModuleName = 'fixedScroll'; -angular - .module(testModuleName, []) - .provider('Private', PrivateProvider) - .service('debounce', ['$timeout', DebounceProviderTimeout]) - .directive('fixedScroll', FixedScrollProvider); +angular.module(testModuleName, []).directive('fixedScroll', FixedScrollProvider); describe('FixedScroll directive', function () { const sandbox = sinon.createSandbox(); @@ -127,7 +122,7 @@ describe('FixedScroll directive', function () { return { $container: $el, $content: $content, - $scroller: $parent.find('.fixed-scroll-scroller'), + $scroller: $parent.find('.dscTableFixedScroll__scroller'), }; }; }); diff --git a/src/plugins/discover/public/application/angular/directives/no_results.js b/src/plugins/discover/public/application/angular/directives/no_results.js index 965c1271c2f2c..d8a39d9178e93 100644 --- a/src/plugins/discover/public/application/angular/directives/no_results.js +++ b/src/plugins/discover/public/application/angular/directives/no_results.js @@ -24,7 +24,6 @@ import PropTypes from 'prop-types'; import { EuiCallOut, EuiCode, - EuiCodeBlock, EuiDescriptionList, EuiFlexGroup, EuiFlexItem, @@ -37,72 +36,12 @@ import { getServices } from '../../../kibana_services'; // eslint-disable-next-line react/prefer-stateless-function export class DiscoverNoResults extends Component { static propTypes = { - shardFailures: PropTypes.array, timeFieldName: PropTypes.string, queryLanguage: PropTypes.string, }; render() { - const { shardFailures, timeFieldName, queryLanguage } = this.props; - - let shardFailuresMessage; - - if (shardFailures && shardFailures.length) { - const failures = shardFailures.map((failure, index) => ( -
- - - - - ), - failureShard: `‘${failure.shard}’`, - }} - /> - - - - - {JSON.stringify(failure.reason)} - - {index < shardFailures.length - 1 ? : undefined} -
- )); - - shardFailuresMessage = ( - - - - -

- -

- -

- -

- - {failures} -
-
- ); - } + const { timeFieldName, queryLanguage } = this.props; let timeFieldMessage; @@ -264,8 +203,6 @@ export class DiscoverNoResults extends Component { iconType="help" data-test-subj="discoverNoResults" /> - - {shardFailuresMessage} {timeFieldMessage} {luceneQueryMessage} diff --git a/src/plugins/discover/public/application/angular/directives/no_results.test.js b/src/plugins/discover/public/application/angular/directives/no_results.test.js index 7de792c612993..60c50048a39ef 100644 --- a/src/plugins/discover/public/application/angular/directives/no_results.test.js +++ b/src/plugins/discover/public/application/angular/directives/no_results.test.js @@ -42,35 +42,6 @@ beforeEach(() => { describe('DiscoverNoResults', () => { describe('props', () => { - describe('shardFailures', () => { - test('renders failures list when there are failures', () => { - const shardFailures = [ - { - index: 'A', - shard: '1', - reason: { reason: 'Awful error' }, - }, - { - index: 'B', - shard: '2', - reason: { reason: 'Bad error' }, - }, - ]; - - const component = renderWithIntl(); - - expect(component).toMatchSnapshot(); - }); - - test(`doesn't render failures list when there are no failures`, () => { - const shardFailures = []; - - const component = renderWithIntl(); - - expect(component).toMatchSnapshot(); - }); - }); - describe('timeFieldName', () => { test('renders time range feedback', () => { const component = renderWithIntl(); diff --git a/src/plugins/discover/public/application/angular/discover.html b/src/plugins/discover/public/application/angular/discover.html deleted file mode 100644 index e0e452aaa41c5..0000000000000 --- a/src/plugins/discover/public/application/angular/discover.html +++ /dev/null @@ -1,160 +0,0 @@ - -

{{screenTitle}}

- - - - - -
-
-
-
- - -
-
- -
- - - - - -
- - - -
- -
- -
- - - - - -
- - - - -
- -
-

- - - - - -
-
-
-
-
-
-
diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index b75ac75e5f2ed..7871cc4b16464 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -29,12 +29,11 @@ import { getState, splitState } from './discover_state'; import { RequestAdapter } from '../../../../inspector/public'; import { SavedObjectSaveModal, showSaveModal } from '../../../../saved_objects/public'; import { getSortArray, getSortForSearchSource } from './doc_table'; +import { createFixedScroll } from './directives/fixed_scroll'; import * as columnActions from './doc_table/actions/columns'; - -import indexTemplate from './discover.html'; +import indexTemplateLegacy from './discover_legacy.html'; import { showOpenSearchPanel } from '../components/top_nav/show_open_search_panel'; import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util'; -import '../components/fetch_error'; import { getPainlessError } from './get_painless_error'; import { discoverResponseHandler } from './response_handler'; import { @@ -71,7 +70,6 @@ import { indexPatterns as indexPatternsUtils, connectToQueryState, syncQueryStateWithUrl, - search, } from '../../../../data/public'; import { getIndexPatternId } from '../helpers/get_index_pattern_id'; import { addFatalError } from '../../../../kibana_legacy/public'; @@ -115,7 +113,7 @@ app.config(($routeProvider) => { }; const discoverRoute = { ...defaults, - template: indexTemplate, + template: indexTemplateLegacy, reloadOnSearch: false, resolve: { savedObjects: function ($route, Promise) { @@ -308,18 +306,10 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise mode: 'absolute', }); }; - $scope.intervalOptions = search.aggs.intervalOptions; $scope.minimumVisibleRows = 50; $scope.fetchStatus = fetchStatuses.UNINITIALIZED; $scope.showSaveQuery = uiCapabilities.discover.saveQuery; - $scope.$watch( - () => uiCapabilities.discover.saveQuery, - (newCapability) => { - $scope.showSaveQuery = newCapability; - } - ); - let abortController; $scope.$on('$destroy', () => { if (abortController) abortController.abort(); @@ -471,7 +461,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise ]; }; $scope.topNavMenu = getTopNavLinks(); - $scope.setHeaderActionMenu = getHeaderActionMenuMounter(); $scope.searchSource .setField('index', $scope.indexPattern) @@ -515,8 +504,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise ]); } - $scope.screenTitle = savedSearch.title; - const getFieldCounts = async () => { // the field counts aren't set until we have the data back, // so we wait for the fetch to be done before proceeding @@ -612,6 +599,9 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise timefield: getTimeField(), savedSearch: savedSearch, indexPatternList: $route.current.locals.savedObjects.ip.list, + config: config, + fixedScroll: createFixedScroll($scope, $timeout), + setHeaderActionMenu: getHeaderActionMenuMounter(), }; const shouldSearchOnPageLoad = () => { @@ -771,6 +761,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise if (!init.complete) return; $scope.fetchCounter++; $scope.fetchError = undefined; + $scope.minimumVisibleRows = 50; if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { $scope.resultState = 'none'; return; @@ -868,9 +859,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise tabifiedData, getDimensions($scope.vis.data.aggs.aggs, $scope.timeRange) ); - if ($scope.vis.data.aggs.aggs[1]) { - $scope.bucketInterval = $scope.vis.data.aggs.aggs[1].buckets.getInterval(); - } $scope.updateTime(); } diff --git a/src/plugins/discover/public/application/angular/discover_legacy.html b/src/plugins/discover/public/application/angular/discover_legacy.html new file mode 100644 index 0000000000000..8582f71c0cb88 --- /dev/null +++ b/src/plugins/discover/public/application/angular/discover_legacy.html @@ -0,0 +1,36 @@ + + + + diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index ac0dc054485f0..5ddb6a92b5fd4 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -55,6 +55,10 @@ export interface AppState { * Array of the used sorting [[field,direction],...] */ sort?: string[][]; + /** + * id of the used saved query + */ + savedQuery?: string; } interface GetStateParams { diff --git a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx new file mode 100644 index 0000000000000..ad2b674af014c --- /dev/null +++ b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx @@ -0,0 +1,131 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import angular, { auto, ICompileService, IScope } from 'angular'; +import { render } from 'react-dom'; +import React, { useRef, useEffect } from 'react'; +import { getServices, IIndexPattern } from '../../../kibana_services'; +import { IndexPatternField } from '../../../../../data/common/index_patterns'; +export type AngularScope = IScope; + +export interface AngularDirective { + template: string; +} + +/** + * Compiles and injects the give angular template into the given dom node + * returns a function to cleanup the injected angular element + */ +export async function injectAngularElement( + domNode: Element, + template: string, + scopeProps: any, + getInjector: () => Promise +): Promise<() => void> { + const $injector = await getInjector(); + const rootScope: AngularScope = $injector.get('$rootScope'); + const $compile: ICompileService = $injector.get('$compile'); + const newScope = Object.assign(rootScope.$new(), scopeProps); + + const $target = angular.element(domNode); + const $element = angular.element(template); + + newScope.$apply(() => { + const linkFn = $compile($element); + $target.empty().append($element); + linkFn(newScope); + }); + + return () => { + newScope.$destroy(); + }; +} + +/** + * Converts a given legacy angular directive to a render function + * for usage in a react component. Note that the rendering is async + */ +export function convertDirectiveToRenderFn( + directive: AngularDirective, + getInjector: () => Promise +) { + return (domNode: Element, props: any) => { + let rejected = false; + + const cleanupFnPromise = injectAngularElement(domNode, directive.template, props, getInjector); + cleanupFnPromise.catch(() => { + rejected = true; + render(
error
, domNode); + }); + + return () => { + if (!rejected) { + // for cleanup + // http://roubenmeschian.com/rubo/?p=51 + cleanupFnPromise.then((cleanup) => cleanup()); + } + }; + }; +} + +export interface DocTableLegacyProps { + columns: string[]; + searchDescription?: string; + searchTitle?: string; + onFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + rows: Array>; + indexPattern: IIndexPattern; + minimumVisibleRows: number; + onAddColumn: (column: string) => void; + onSort: (sort: string[][]) => void; + onMoveColumn: (columns: string, newIdx: number) => void; + onRemoveColumn: (column: string) => void; + sort?: string[][]; +} + +export function DocTableLegacy(renderProps: DocTableLegacyProps) { + const renderFn = convertDirectiveToRenderFn( + { + template: ``, + }, + () => getServices().getEmbeddableInjector() + ); + const ref = useRef(null); + useEffect(() => { + if (ref && ref.current) { + return renderFn(ref.current, renderProps); + } + }, [renderFn, renderProps]); + return
; +} diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.ts b/src/plugins/discover/public/application/angular/doc_table/doc_table.ts index f972c158ff3dd..735ee9f555740 100644 --- a/src/plugins/discover/public/application/angular/doc_table/doc_table.ts +++ b/src/plugins/discover/public/application/angular/doc_table/doc_table.ts @@ -50,10 +50,6 @@ export function createDocTableDirective(pagerFactory: any, $filter: any) { inspectorAdapters: '=?', }, link: ($scope: LazyScope, $el: JQuery) => { - $scope.$watch('minimumVisibleRows', (minimumVisibleRows: number) => { - $scope.limit = Math.max(minimumVisibleRows || 50, $scope.limit || 50); - }); - $scope.persist = { sorting: $scope.sorting, columns: $scope.columns, @@ -77,7 +73,7 @@ export function createDocTableDirective(pagerFactory: any, $filter: any) { if (!hits) return; // Reset infinite scroll limit - $scope.limit = 50; + $scope.limit = $scope.minimumVisibleRows || 50; if (hits.length === 0) { dispatchRenderComplete($el[0]); diff --git a/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts b/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts new file mode 100644 index 0000000000000..a3502cbb211fa --- /dev/null +++ b/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts @@ -0,0 +1,56 @@ +/* + * 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 { DiscoverLegacy } from './discover_legacy'; + +export function createDiscoverLegacyDirective(reactDirective: any) { + return reactDirective(DiscoverLegacy, [ + ['addColumn', { watchDepth: 'reference' }], + ['fetch', { watchDepth: 'reference' }], + ['fetchCounter', { watchDepth: 'reference' }], + ['fetchError', { watchDepth: 'reference' }], + ['fieldCounts', { watchDepth: 'reference' }], + ['histogramData', { watchDepth: 'reference' }], + ['hits', { watchDepth: 'reference' }], + ['indexPattern', { watchDepth: 'reference' }], + ['minimumVisibleRows', { watchDepth: 'reference' }], + ['onAddFilter', { watchDepth: 'reference' }], + ['onChangeInterval', { watchDepth: 'reference' }], + ['onMoveColumn', { watchDepth: 'reference' }], + ['onRemoveColumn', { watchDepth: 'reference' }], + ['onSetColumns', { watchDepth: 'reference' }], + ['onSkipBottomButtonClick', { watchDepth: 'reference' }], + ['onSort', { watchDepth: 'reference' }], + ['opts', { watchDepth: 'reference' }], + ['resetQuery', { watchDepth: 'reference' }], + ['resultState', { watchDepth: 'reference' }], + ['rows', { watchDepth: 'reference' }], + ['savedSearch', { watchDepth: 'reference' }], + ['searchSource', { watchDepth: 'reference' }], + ['setIndexPattern', { watchDepth: 'reference' }], + ['showSaveQuery', { watchDepth: 'reference' }], + ['state', { watchDepth: 'reference' }], + ['timefilterUpdateHandler', { watchDepth: 'reference' }], + ['timeRange', { watchDepth: 'reference' }], + ['topNavMenu', { watchDepth: 'reference' }], + ['updateQuery', { watchDepth: 'reference' }], + ['updateSavedQueryId', { watchDepth: 'reference' }], + ['vis', { watchDepth: 'reference' }], + ]); +} diff --git a/src/plugins/discover/public/application/components/discover_legacy.tsx b/src/plugins/discover/public/application/components/discover_legacy.tsx new file mode 100644 index 0000000000000..1a98843649259 --- /dev/null +++ b/src/plugins/discover/public/application/components/discover_legacy.tsx @@ -0,0 +1,324 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState, useCallback, useEffect } from 'react'; +import classNames from 'classnames'; +import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { IUiSettingsClient, MountPoint } from 'kibana/public'; +import { HitsCounter } from './hits_counter'; +import { TimechartHeader } from './timechart_header'; +import { DiscoverSidebar } from './sidebar'; +import { getServices, IIndexPattern } from '../../kibana_services'; +// @ts-ignore +import { DiscoverNoResults } from '../angular/directives/no_results'; +import { DiscoverUninitialized } from '../angular/directives/uninitialized'; +import { DiscoverHistogram } from '../angular/directives/histogram'; +import { LoadingSpinner } from './loading_spinner/loading_spinner'; +import { DiscoverFetchError, FetchError } from './fetch_error/fetch_error'; +import { DocTableLegacy } from '../angular/doc_table/create_doc_table_react'; +import { SkipBottomButton } from './skip_bottom_button'; +import { + IndexPatternField, + search, + ISearchSource, + TimeRange, + Query, + IndexPatternAttributes, +} from '../../../../data/public'; +import { Chart } from '../angular/helpers/point_series'; +import { AppState } from '../angular/discover_state'; +import { SavedSearch } from '../../saved_searches'; + +import { SavedObject } from '../../../../../core/types'; +import { Vis } from '../../../../visualizations/public'; +import { TopNavMenuData } from '../../../../navigation/public'; + +export interface DiscoverLegacyProps { + addColumn: (column: string) => void; + fetch: () => void; + fetchCounter: number; + fetchError: FetchError; + fieldCounts: Record; + histogramData: Chart; + hits: number; + indexPattern: IIndexPattern; + minimumVisibleRows: number; + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + onChangeInterval: (interval: string) => void; + onMoveColumn: (columns: string, newIdx: number) => void; + onRemoveColumn: (column: string) => void; + onSetColumns: (columns: string[]) => void; + onSkipBottomButtonClick: () => void; + onSort: (sort: string[][]) => void; + opts: { + savedSearch: SavedSearch; + config: IUiSettingsClient; + indexPatternList: Array>; + timefield: string; + sampleSize: number; + fixedScroll: (el: HTMLElement) => void; + setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + }; + resetQuery: () => void; + resultState: string; + rows: Array>; + searchSource: ISearchSource; + setIndexPattern: (id: string) => void; + showSaveQuery: boolean; + state: AppState; + timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; + timeRange?: { from: string; to: string }; + topNavMenu: TopNavMenuData[]; + updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; + updateSavedQueryId: (savedQueryId?: string) => void; + vis?: Vis; +} + +export function DiscoverLegacy({ + addColumn, + fetch, + fetchCounter, + fetchError, + fieldCounts, + histogramData, + hits, + indexPattern, + minimumVisibleRows, + onAddFilter, + onChangeInterval, + onMoveColumn, + onRemoveColumn, + onSkipBottomButtonClick, + onSort, + opts, + resetQuery, + resultState, + rows, + searchSource, + setIndexPattern, + showSaveQuery, + state, + timefilterUpdateHandler, + timeRange, + topNavMenu, + updateQuery, + updateSavedQueryId, + vis, +}: DiscoverLegacyProps) { + const [isSidebarClosed, setIsSidebarClosed] = useState(false); + const { TopNavMenu } = getServices().navigation.ui; + const { savedSearch, indexPatternList } = opts; + const bucketAggConfig = vis?.data?.aggs?.aggs[1]; + const bucketInterval = + bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig) + ? bucketAggConfig.buckets?.getInterval() + : undefined; + const [fixedScrollEl, setFixedScrollEl] = useState(); + + useEffect(() => (fixedScrollEl ? opts.fixedScroll(fixedScrollEl) : undefined), [ + fixedScrollEl, + opts, + ]); + const fixedScrollRef = useCallback( + (node: HTMLElement) => { + if (node !== null) { + setFixedScrollEl(node); + } + }, + [setFixedScrollEl] + ); + const sidebarClassName = classNames({ + closed: isSidebarClosed, + }); + + const mainSectionClassName = classNames({ + 'col-md-10': !isSidebarClosed, + 'col-md-12': isSidebarClosed, + }); + + return ( + +
+

{savedSearch.title}

+ +
+
+
+ {!isSidebarClosed && ( +
+ +
+ )} + setIsSidebarClosed(!isSidebarClosed)} + data-test-subj="collapseSideBarButton" + aria-controls="discover-sidebar" + aria-expanded={isSidebarClosed ? 'false' : 'true'} + aria-label="Toggle sidebar" + className="dscCollapsibleSidebar__collapseButton" + /> +
+
+ {resultState === 'none' && ( + + )} + {resultState === 'uninitialized' && } + {/* @TODO: Solved in the Angular way to satisfy functional test - should be improved*/} + + {fetchError && } +
+ +
+
+ {resultState === 'ready' && ( +
+ + 0 ? hits : 0} + showResetButton={!!(savedSearch && savedSearch.id)} + onResetQuery={resetQuery} + /> + {opts.timefield && ( + + )} + + {opts.timefield && ( +
+ {vis && rows.length !== 0 && ( +
+ +
+ )} +
+ )} + +
+
+

+ +

+ {rows && rows.length && ( +
+ + + ​ + + {rows.length === opts.sampleSize && ( +
+ + + window.scrollTo(0, 0)}> + + +
+ )} +
+ )} +
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx b/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx index 880a493983adf..dc8f1238eac6f 100644 --- a/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx +++ b/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx @@ -20,18 +20,20 @@ import './fetch_error.scss'; import React, { Fragment } from 'react'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiCallOut, EuiCodeBlock, EuiSpacer } from '@elastic/eui'; -import { getAngularModule, getServices } from '../../../kibana_services'; +import { getServices } from '../../../kibana_services'; + +export interface FetchError { + lang: string; + script: string; + message: string; + error: string; +} interface Props { - fetchError: { - lang: string; - script: string; - message: string; - error: string; - }; + fetchError: FetchError; } -const DiscoverFetchError = ({ fetchError }: Props) => { +export const DiscoverFetchError = ({ fetchError }: Props) => { if (!fetchError) { return null; } @@ -92,9 +94,3 @@ const DiscoverFetchError = ({ fetchError }: Props) => { ); }; - -export function createFetchErrorDirective(reactDirective: any) { - return reactDirective(DiscoverFetchError); -} - -getAngularModule().directive('discoverFetchError', createFetchErrorDirective); diff --git a/src/plugins/discover/public/application/components/fetch_error/index.js b/src/plugins/discover/public/application/components/fetch_error/index.ts similarity index 100% rename from src/plugins/discover/public/application/components/fetch_error/index.js rename to src/plugins/discover/public/application/components/fetch_error/index.ts diff --git a/src/plugins/discover/public/application/components/hits_counter/hits_counter_directive.ts b/src/plugins/discover/public/application/components/hits_counter/hits_counter_directive.ts deleted file mode 100644 index 8d45e28370cad..0000000000000 --- a/src/plugins/discover/public/application/components/hits_counter/hits_counter_directive.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { HitsCounter } from './hits_counter'; - -export function createHitsCounterDirective(reactDirective: any) { - return reactDirective(HitsCounter, [ - ['hits', { watchDepth: 'reference' }], - ['showResetButton', { watchDepth: 'reference' }], - ['onResetQuery', { watchDepth: 'reference' }], - ]); -} diff --git a/src/plugins/discover/public/application/components/hits_counter/index.ts b/src/plugins/discover/public/application/components/hits_counter/index.ts index 58e7a9eda7f51..0ce95f061df17 100644 --- a/src/plugins/discover/public/application/components/hits_counter/index.ts +++ b/src/plugins/discover/public/application/components/hits_counter/index.ts @@ -18,4 +18,3 @@ */ export { HitsCounter } from './hits_counter'; -export { createHitsCounterDirective } from './hits_counter_directive'; diff --git a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx index 44b922bf0f708..4e1754638d479 100644 --- a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx +++ b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx @@ -18,24 +18,18 @@ */ import React from 'react'; import { EuiLoadingSpinner, EuiTitle, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; export function LoadingSpinner() { return ( - - <> - -

- -

-
- - - -
+ <> + +

+ +

+
+ + + ); } - -export function createLoadingSpinnerDirective(reactDirective: any) { - return reactDirective(LoadingSpinner); -} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index 850624888b24a..2407cff181901 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -68,7 +68,7 @@ export interface DiscoverSidebarProps { /** * Currently selected index pattern */ - selectedIndexPattern: IndexPattern; + selectedIndexPattern?: IndexPattern; /** * Callback function to select another index pattern */ diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_directive.ts b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_directive.ts deleted file mode 100644 index b271c920e5e01..0000000000000 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_directive.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { DiscoverSidebar } from './discover_sidebar'; - -export function createDiscoverSidebarDirective(reactDirective: any) { - return reactDirective(DiscoverSidebar, [ - ['columns', { watchDepth: 'reference' }], - ['fieldCounts', { watchDepth: 'reference' }], - ['hits', { watchDepth: 'reference' }], - ['indexPatternList', { watchDepth: 'reference' }], - ['onAddField', { watchDepth: 'reference' }], - ['onAddFilter', { watchDepth: 'reference' }], - ['onRemoveField', { watchDepth: 'reference' }], - ['selectedIndexPattern', { watchDepth: 'reference' }], - ['setIndexPattern', { watchDepth: 'reference' }], - ]); -} diff --git a/src/plugins/discover/public/application/components/sidebar/index.ts b/src/plugins/discover/public/application/components/sidebar/index.ts index 1b837840b52f6..aec8dfc86e817 100644 --- a/src/plugins/discover/public/application/components/sidebar/index.ts +++ b/src/plugins/discover/public/application/components/sidebar/index.ts @@ -18,4 +18,3 @@ */ export { DiscoverSidebar } from './discover_sidebar'; -export { createDiscoverSidebarDirective } from './discover_sidebar_directive'; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts b/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts index 13051f88c9591..22a6e7a628555 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts @@ -25,8 +25,11 @@ export function getDetails( field: IndexPatternField, hits: Array>, columns: string[], - indexPattern: IndexPattern + indexPattern?: IndexPattern ) { + if (!indexPattern) { + return {}; + } const details = { ...fieldCalculator.getFieldValueCounts({ hits, diff --git a/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts b/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts index c96a8f5ce17b9..eff7c2ec3c1c8 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts @@ -20,8 +20,8 @@ import { difference } from 'lodash'; import { IndexPattern, IndexPatternField } from 'src/plugins/data/public'; export function getIndexPatternFieldList( - indexPattern: IndexPattern, - fieldCounts: Record + indexPattern?: IndexPattern, + fieldCounts?: Record ) { if (!indexPattern || !fieldCounts) return []; diff --git a/src/plugins/discover/public/application/components/skip_bottom_button/index.ts b/src/plugins/discover/public/application/components/skip_bottom_button/index.ts index 2feaa35e0d61f..b3d93e40be0bd 100644 --- a/src/plugins/discover/public/application/components/skip_bottom_button/index.ts +++ b/src/plugins/discover/public/application/components/skip_bottom_button/index.ts @@ -18,4 +18,3 @@ */ export { SkipBottomButton } from './skip_bottom_button'; -export { createSkipBottomButtonDirective } from './skip_bottom_button_directive'; diff --git a/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button_directive.ts b/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button_directive.ts deleted file mode 100644 index 27f17b25fd447..0000000000000 --- a/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button_directive.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { SkipBottomButton } from './skip_bottom_button'; - -export function createSkipBottomButtonDirective(reactDirective: any) { - return reactDirective(SkipBottomButton, [['onClick', { watchDepth: 'reference' }]]); -} diff --git a/src/plugins/discover/public/application/components/timechart_header/index.ts b/src/plugins/discover/public/application/components/timechart_header/index.ts index 43473319c318c..34bed2cd72a74 100644 --- a/src/plugins/discover/public/application/components/timechart_header/index.ts +++ b/src/plugins/discover/public/application/components/timechart_header/index.ts @@ -18,4 +18,3 @@ */ export { TimechartHeader } from './timechart_header'; -export { createTimechartHeaderDirective } from './timechart_header_directive'; diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx b/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx index a4c10e749d868..7889b05a88415 100644 --- a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx +++ b/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx @@ -29,8 +29,10 @@ describe('timechart header', function () { beforeAll(() => { props = { - from: 'May 14, 2020 @ 11:05:13.590', - to: 'May 14, 2020 @ 11:20:13.590', + timeRange: { + from: 'May 14, 2020 @ 11:05:13.590', + to: 'May 14, 2020 @ 11:20:13.590', + }, stateInterval: 's', options: [ { @@ -47,9 +49,11 @@ describe('timechart header', function () { }, ], onChangeInterval: jest.fn(), - showScaledInfo: undefined, - bucketIntervalDescription: 'second', - bucketIntervalScale: undefined, + bucketInterval: { + scaled: undefined, + description: 'second', + scale: undefined, + }, }; }); @@ -58,8 +62,8 @@ describe('timechart header', function () { expect(component.find(EuiIconTip).length).toBe(0); }); - it('TimechartHeader renders an info text by providing the showScaledInfo property', () => { - props.showScaledInfo = true; + it('TimechartHeader renders an info when bucketInterval.scale is set to true', () => { + props.bucketInterval!.scaled = true; component = mountWithIntl(); expect(component.find(EuiIconTip).length).toBe(1); }); diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx b/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx index 8789847058aff..1451106827ee0 100644 --- a/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx +++ b/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -27,16 +27,28 @@ import { } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import moment from 'moment'; export interface TimechartHeaderProps { /** - * the query from date string + * Format of date to be displayed */ - from: string; + dateFormat?: string; /** - * the query to date string + * Interval for the buckets of the recent request */ - to: string; + bucketInterval?: { + scaled?: boolean; + description?: string; + scale?: number; + }; + /** + * Range of dates to be displayed + */ + timeRange?: { + from: string; + to: string; + }; /** * Interval Options */ @@ -49,31 +61,29 @@ export interface TimechartHeaderProps { * selected interval */ stateInterval: string; - /** - * displays the scaled info of the interval - */ - showScaledInfo: boolean | undefined; - /** - * scaled info description - */ - bucketIntervalDescription: string; - /** - * bucket interval scale - */ - bucketIntervalScale: number | undefined; } export function TimechartHeader({ - from, - to, + bucketInterval, + dateFormat, + timeRange, options, onChangeInterval, stateInterval, - showScaledInfo, - bucketIntervalDescription, - bucketIntervalScale, }: TimechartHeaderProps) { const [interval, setInterval] = useState(stateInterval); + const toMoment = useCallback( + (datetime: string) => { + if (!datetime) { + return ''; + } + if (!dateFormat) { + return datetime; + } + return moment(datetime).format(dateFormat); + }, + [dateFormat] + ); useEffect(() => { setInterval(stateInterval); @@ -84,6 +94,10 @@ export function TimechartHeader({ onChangeInterval(e.target.value); }; + if (!timeRange || !bucketInterval) { + return null; + } + return ( @@ -95,7 +109,7 @@ export function TimechartHeader({ delay="long" > - {`${from} - ${to} ${ + {`${toMoment(timeRange.from)} - ${toMoment(timeRange.to)} ${ interval !== 'auto' ? i18n.translate('discover.timechartHeader.timeIntervalSelect.per', { defaultMessage: 'per', @@ -125,7 +139,7 @@ export function TimechartHeader({ value={interval} onChange={handleIntervalChange} append={ - showScaledInfo ? ( + bucketInterval.scaled ? ( 1 + bucketInterval!.scale && bucketInterval!.scale > 1 ? i18n.translate('discover.bucketIntervalTooltip.tooLargeBucketsText', { defaultMessage: 'buckets that are too large', }) : i18n.translate('discover.bucketIntervalTooltip.tooManyBucketsText', { defaultMessage: 'too many buckets', }), - bucketIntervalDescription, + bucketIntervalDescription: bucketInterval.description, }, })} color="warning" diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header_directive.ts b/src/plugins/discover/public/application/components/timechart_header/timechart_header_directive.ts deleted file mode 100644 index 027236cd46521..0000000000000 --- a/src/plugins/discover/public/application/components/timechart_header/timechart_header_directive.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { TimechartHeader } from './timechart_header'; - -export function createTimechartHeaderDirective(reactDirective: any) { - return reactDirective(TimechartHeader, [ - ['from', { watchDepth: 'reference' }], - ['to', { watchDepth: 'reference' }], - ['options', { watchDepth: 'reference' }], - ['onChangeInterval', { watchDepth: 'reference' }], - ['stateInterval', { watchDepth: 'reference' }], - ['showScaledInfo', { watchDepth: 'reference' }], - ['bucketIntervalDescription', { watchDepth: 'reference' }], - ['bucketIntervalScale', { watchDepth: 'reference' }], - ]); -} diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 12562d8571a25..fdb14b3f1f63e 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -44,6 +44,7 @@ import { createSavedSearchesLoader, SavedSearch } from './saved_searches'; import { getHistory } from './kibana_services'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; import { UrlForwardingStart } from '../../url_forwarding/public'; +import { NavigationPublicPluginStart } from '../../navigation/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -58,6 +59,7 @@ export interface DiscoverServices { indexPatterns: IndexPatternsContract; inspector: InspectorPublicPluginStart; metadata: { branch: string }; + navigation: NavigationPublicPluginStart; share?: SharePluginStart; kibanaLegacy: KibanaLegacyStart; urlForwarding: UrlForwardingStart; @@ -65,6 +67,7 @@ export interface DiscoverServices { toastNotifications: ToastsStart; getSavedSearchById: (id: string) => Promise; getSavedSearchUrlById: (id: string) => Promise; + getEmbeddableInjector: any; uiSettings: IUiSettingsClient; visualizations: VisualizationsStart; } @@ -72,7 +75,8 @@ export interface DiscoverServices { export async function buildServices( core: CoreStart, plugins: DiscoverStartPlugins, - context: PluginInitializerContext + context: PluginInitializerContext, + getEmbeddableInjector: any ): Promise { const services: SavedObjectKibanaServices = { savedObjectsClient: core.savedObjects.client, @@ -92,6 +96,7 @@ export async function buildServices( docLinks: core.docLinks, theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, + getEmbeddableInjector, getSavedSearchById: async (id: string) => savedObjectService.get(id), getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id), history: getHistory, @@ -100,6 +105,7 @@ export async function buildServices( metadata: { branch: context.env.packageInfo.branch, }, + navigation: plugins.navigation, share: plugins.share, kibanaLegacy: plugins.kibanaLegacy, urlForwarding: plugins.urlForwarding, diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts index 85b0752f13463..1ca0bb20e8723 100644 --- a/src/plugins/discover/public/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -40,16 +40,10 @@ import { createTableRowDirective } from './application/angular/doc_table/compone import { createPagerFactory } from './application/angular/doc_table/lib/pager/pager_factory'; import { createInfiniteScrollDirective } from './application/angular/doc_table/infinite_scroll'; import { createDocViewerDirective } from './application/angular/doc_viewer'; -import { CollapsibleSidebarProvider } from './application/angular/directives/collapsible_sidebar/collapsible_sidebar'; -// @ts-ignore -import { FixedScrollProvider } from './application/angular/directives/fixed_scroll'; -// @ts-ignore -import { DebounceProviderTimeout } from './application/angular/directives/debounce/debounce'; import { createRenderCompleteDirective } from './application/angular/directives/render_complete'; import { initAngularBootstrap, configureAppAngularModule, - KbnAccessibleClickProvider, PrivateProvider, PromiseServiceCreator, registerListenEventListener, @@ -57,14 +51,10 @@ import { createTopNavDirective, createTopNavHelper, } from '../../kibana_legacy/public'; -import { createDiscoverSidebarDirective } from './application/components/sidebar'; -import { createHitsCounterDirective } from '././application/components/hits_counter'; -import { createLoadingSpinnerDirective } from '././application/components/loading_spinner/loading_spinner'; -import { createTimechartHeaderDirective } from './application/components/timechart_header'; import { createContextErrorMessageDirective } from './application/components/context_error_message'; import { DiscoverStartPlugins } from './plugin'; import { getScopedHistory } from './kibana_services'; -import { createSkipBottomButtonDirective } from './application/components/skip_bottom_button'; +import { createDiscoverLegacyDirective } from './application/components/create_discover_legacy_directive'; /** * returns the main inner angular module, it contains all the parts of Angular Discover @@ -88,11 +78,9 @@ export function getInnerAngularModule( export function getInnerAngularModuleEmbeddable( name: string, core: CoreStart, - deps: DiscoverStartPlugins, - context: PluginInitializerContext + deps: DiscoverStartPlugins ) { - const module = initializeInnerAngularModule(name, core, deps.navigation, deps.data, true); - return module; + return initializeInnerAngularModule(name, core, deps.navigation, deps.data, true); } let initialized = false; @@ -129,8 +117,7 @@ export function initializeInnerAngularModule( ]) .config(watchMultiDecorator) .directive('icon', (reactDirective) => reactDirective(EuiIcon)) - .directive('renderComplete', createRenderCompleteDirective) - .service('debounce', ['$timeout', DebounceProviderTimeout]); + .directive('renderComplete', createRenderCompleteDirective); } return angular @@ -149,18 +136,9 @@ export function initializeInnerAngularModule( ]) .config(watchMultiDecorator) .run(registerListenEventListener) - .directive('icon', (reactDirective) => reactDirective(EuiIcon)) - .directive('kbnAccessibleClick', KbnAccessibleClickProvider) - .directive('collapsibleSidebar', CollapsibleSidebarProvider) - .directive('fixedScroll', FixedScrollProvider) .directive('renderComplete', createRenderCompleteDirective) - .directive('discoverSidebar', createDiscoverSidebarDirective) - .directive('skipBottomButton', createSkipBottomButtonDirective) - .directive('hitsCounter', createHitsCounterDirective) - .directive('loadingSpinner', createLoadingSpinnerDirective) - .directive('timechartHeader', createTimechartHeaderDirective) - .directive('contextErrorMessage', createContextErrorMessageDirective) - .service('debounce', ['$timeout', DebounceProviderTimeout]); + .directive('discoverLegacy', createDiscoverLegacyDirective) + .directive('contextErrorMessage', createContextErrorMessageDirective); } function createLocalPromiseModule() { diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index dd9b57b568e42..440bd3fdf86d3 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -327,7 +327,12 @@ export class DiscoverPlugin if (this.servicesInitialized) { return { core, plugins }; } - const services = await buildServices(core, plugins, this.initializerContext); + const services = await buildServices( + core, + plugins, + this.initializerContext, + this.getEmbeddableInjector + ); setServices(services); this.servicesInitialized = true; @@ -380,12 +385,7 @@ export class DiscoverPlugin const { core, plugins } = await this.initializeServices(); getServices().kibanaLegacy.loadFontAwesome(); const { getInnerAngularModuleEmbeddable } = await import('./get_inner_angular'); - getInnerAngularModuleEmbeddable( - embeddableAngularName, - core, - plugins, - this.initializerContext - ); + getInnerAngularModuleEmbeddable(embeddableAngularName, core, plugins); const mountpoint = document.createElement('div'); this.embeddableInjector = angular.bootstrap(mountpoint, [embeddableAngularName]); } diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index d601d087afcee..13361cb647ddc 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -28,6 +28,7 @@ export interface SavedSearch { columns: string[]; sort: SortOrder[]; destroy: () => void; + lastSavedTitle?: string; } export interface SavedSearchLoader { get: (id: string) => Promise; diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 5a224d930ee42..7a99509257bf7 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -254,7 +254,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider } public async getSidebarWidth() { - const sidebar = await find.byCssSelector('.sidebar-list'); + const sidebar = await testSubjects.find('discover-sidebar'); return await sidebar.getAttribute('clientWidth'); } diff --git a/x-pack/package.json b/x-pack/package.json index 59a43b1f344c8..94e072eebec51 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -101,7 +101,7 @@ "@types/mocha": "^7.0.2", "@types/nock": "^10.0.3", "@types/node": ">=10.17.17 <10.20.0", - "@types/node-fetch": "^2.5.0", + "@types/node-fetch": "^2.5.7", "@types/nodemailer": "^6.2.1", "@types/object-hash": "^1.3.0", "@types/papaparse": "^5.0.3", @@ -209,7 +209,6 @@ "mochawesome-merge": "^4.1.0", "mustache": "^2.3.0", "mutation-observer": "^1.0.3", - "node-fetch": "^2.6.0", "null-loader": "^3.0.0", "oboe": "^2.1.4", "pixelmatch": "^5.1.0", @@ -345,7 +344,7 @@ "moment-timezone": "^0.5.27", "ngreact": "^0.5.1", "nock": "12.0.3", - "node-fetch": "^2.6.0", + "node-fetch": "^2.6.1", "nodemailer": "^4.7.0", "object-hash": "^1.3.1", "object-path-immutable": "^3.1.1", diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx index 85d975870d9bc..e806f556347f1 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx @@ -70,6 +70,7 @@ export function ErroneousTransactionsRateChart() { { + const message = 'I am a message'; + + beforeEach(() => { + FlashMessagesLogic.mount(); + }); + + it('setSuccessMessage()', () => { + setSuccessMessage(message); + + expect(FlashMessagesLogic.values.messages).toEqual([ + { + message, + type: 'success', + }, + ]); + }); + + it('setErrorMessage()', () => { + setErrorMessage(message); + + expect(FlashMessagesLogic.values.messages).toEqual([ + { + message, + type: 'error', + }, + ]); + }); + + it('setQueuedSuccessMessage()', () => { + setQueuedSuccessMessage(message); + + expect(FlashMessagesLogic.values.queuedMessages).toEqual([ + { + message, + type: 'success', + }, + ]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.ts new file mode 100644 index 0000000000000..6abb540b7c14b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FlashMessagesLogic } from './'; + +export const setSuccessMessage = (message: string) => { + FlashMessagesLogic.actions.setFlashMessages({ + type: 'success', + message, + }); +}; + +export const setErrorMessage = (message: string) => { + FlashMessagesLogic.actions.setFlashMessages({ + type: 'error', + message, + }); +}; + +export const setQueuedSuccessMessage = (message: string) => { + FlashMessagesLogic.actions.setQueuedMessages({ + type: 'success', + message, + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss index d673542ba1983..79cd7634cfaa0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss @@ -72,4 +72,15 @@ $euiSizeML: $euiSize * 1.25; // 20px - between medium and large ¯\_(ツ)_/¯ } } } + + &__subNav { + padding-left: $euiSizeML; + + // Extends the click area of links more to the left, so that second tiers + // of subnavigation links still have the same hitbox as first tier links + .enterpriseSearchNavLinks__item { + margin-left: -$euiSizeML; + padding-left: $euiSizeXXL; + } + } } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx index c117fa404a16b..b006068ac0d9e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx @@ -89,4 +89,20 @@ describe('SideNavLink', () => { expect(wrapper.find('.testing')).toHaveLength(1); expect(wrapper.find('[data-test-subj="testing"]')).toHaveLength(1); }); + + it('renders nested subnavigation', () => { + const subNav = ( + + Another link! + + ); + const wrapper = shallow( + + Link + + ); + + expect(wrapper.find('.enterpriseSearchNavLinks__subNav')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="subNav"]')).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx index 72e4f2f091496..edcfc2c84e3ad 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx @@ -66,6 +66,7 @@ interface ISideNavLinkProps { isExternal?: boolean; className?: string; isRoot?: boolean; + subNav?: React.ReactNode; } export const SideNavLink: React.FC = ({ @@ -74,6 +75,7 @@ export const SideNavLink: React.FC = ({ children, className, isRoot, + subNav, ...rest }) => { const { closeNavigation } = useContext(NavContext) as INavContext; @@ -103,6 +105,7 @@ export const SideNavLink: React.FC = ({ {children} )} + {subNav &&
    {subNav}
} ); }; diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index e8ea886cbf9f5..f87ebb3d2c404 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -11,7 +11,7 @@ import { AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL, AGENT_POLLING_REQUEST_TIMEOUT_MS, } from '../common'; -export { AgentService, ESIndexPatternService, getRegistryUrl } from './services'; +export { AgentService, ESIndexPatternService, getRegistryUrl, PackageService } from './services'; export { IngestManagerSetupContract, IngestManagerSetupDeps, diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index b10f3527a0459..47900415466b9 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -13,6 +13,7 @@ import { PluginInitializerContext, SavedObjectsServiceStart, HttpServiceSetup, + SavedObjectsClientContract, } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { LicensingPluginSetup, ILicense } from '../../licensing/server'; @@ -47,7 +48,7 @@ import { registerSettingsRoutes, registerAppRoutes, } from './routes'; -import { IngestManagerConfigType, NewPackagePolicy } from '../common'; +import { EsAssetReference, IngestManagerConfigType, NewPackagePolicy } from '../common'; import { appContextService, licenseService, @@ -55,6 +56,7 @@ import { ESIndexPatternService, AgentService, packagePolicyService, + PackageService, } from './services'; import { getAgentStatusById, @@ -65,6 +67,7 @@ import { import { CloudSetup } from '../../cloud/server'; import { agentCheckinState } from './services/agents/checkin/state'; import { registerIngestManagerUsageCollector } from './collectors/register'; +import { getInstallation } from './services/epm/packages'; export interface IngestManagerSetupDeps { licensing: LicensingPluginSetup; @@ -118,6 +121,7 @@ export type ExternalCallbacksStorage = Map => { + const installation = await getInstallation({ savedObjectsClient, pkgName }); + return installation?.installed_es || []; + }, + }, agentService: { getAgent, listAgents, diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts index 1e58319183c7d..83ad08d09de76 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract } from 'kibana/server'; +import { Logger, SavedObjectsClientContract } from 'kibana/server'; import { saveInstalledEsRefs } from '../../packages/install'; import * as Registry from '../../registry'; @@ -33,89 +33,100 @@ export const installTransformForDataset = async ( registryPackage: RegistryPackage, paths: string[], callCluster: CallESAsCurrentUser, - savedObjectsClient: SavedObjectsClientContract + savedObjectsClient: SavedObjectsClientContract, + logger: Logger ) => { - const installation = await getInstallation({ savedObjectsClient, pkgName: registryPackage.name }); - let previousInstalledTransformEsAssets: EsAssetReference[] = []; - if (installation) { - previousInstalledTransformEsAssets = installation.installed_es.filter( - ({ type, id }) => type === ElasticsearchAssetType.transform + try { + const installation = await getInstallation({ + savedObjectsClient, + pkgName: registryPackage.name, + }); + let previousInstalledTransformEsAssets: EsAssetReference[] = []; + if (installation) { + previousInstalledTransformEsAssets = installation.installed_es.filter( + ({ type, id }) => type === ElasticsearchAssetType.transform + ); + } + + // delete all previous transform + await deleteTransforms( + callCluster, + previousInstalledTransformEsAssets.map((asset) => asset.id) ); - } - - // delete all previous transform - await deleteTransforms( - callCluster, - previousInstalledTransformEsAssets.map((asset) => asset.id) - ); - // install the latest dataset - const datasets = registryPackage.datasets; - if (!datasets?.length) return []; - const installNameSuffix = `${registryPackage.version}`; - - const transformPaths = paths.filter((path) => isTransform(path)); - let installedTransforms: EsAssetReference[] = []; - if (transformPaths.length > 0) { - const transformPathDatasets = datasets.reduce((acc, dataset) => { - transformPaths.forEach((path) => { - if (isDatasetTransform(path, dataset.path)) { - acc.push({ path, dataset }); - } - }); - return acc; - }, []); - - const transformRefs = transformPathDatasets.reduce( - (acc, transformPathDataset) => { - if (transformPathDataset) { - acc.push({ - id: getTransformNameForInstallation(transformPathDataset, installNameSuffix), - type: ElasticsearchAssetType.transform, - }); - } + // install the latest dataset + const datasets = registryPackage.datasets; + if (!datasets?.length) return []; + const installNameSuffix = `${registryPackage.version}`; + + const transformPaths = paths.filter((path) => isTransform(path)); + let installedTransforms: EsAssetReference[] = []; + if (transformPaths.length > 0) { + const transformPathDatasets = datasets.reduce((acc, dataset) => { + transformPaths.forEach((path) => { + if (isDatasetTransform(path, dataset.path)) { + acc.push({ path, dataset }); + } + }); return acc; - }, - [] - ); - - // get and save transform refs before installing transforms - await saveInstalledEsRefs(savedObjectsClient, registryPackage.name, transformRefs); - - const transforms: TransformInstallation[] = transformPathDatasets.map( - (transformPathDataset: TransformPathDataset) => { - return { - installationName: getTransformNameForInstallation( - transformPathDataset, - installNameSuffix - ), - content: getAsset(transformPathDataset.path).toString('utf-8'), - }; - } - ); + }, []); + + const transformRefs = transformPathDatasets.reduce( + (acc, transformPathDataset) => { + if (transformPathDataset) { + acc.push({ + id: getTransformNameForInstallation(transformPathDataset, installNameSuffix), + type: ElasticsearchAssetType.transform, + }); + } + return acc; + }, + [] + ); + + // get and save transform refs before installing transforms + await saveInstalledEsRefs(savedObjectsClient, registryPackage.name, transformRefs); + + const transforms: TransformInstallation[] = transformPathDatasets.map( + (transformPathDataset: TransformPathDataset) => { + return { + installationName: getTransformNameForInstallation( + transformPathDataset, + installNameSuffix + ), + content: getAsset(transformPathDataset.path).toString('utf-8'), + }; + } + ); - const installationPromises = transforms.map(async (transform) => { - return installTransform({ callCluster, transform }); - }); + const installationPromises = transforms.map(async (transform) => { + return installTransform({ callCluster, transform, logger }); + }); - installedTransforms = await Promise.all(installationPromises).then((results) => results.flat()); - } + installedTransforms = await Promise.all(installationPromises).then((results) => + results.flat() + ); + } - if (previousInstalledTransformEsAssets.length > 0) { - const currentInstallation = await getInstallation({ - savedObjectsClient, - pkgName: registryPackage.name, - }); + if (previousInstalledTransformEsAssets.length > 0) { + const currentInstallation = await getInstallation({ + savedObjectsClient, + pkgName: registryPackage.name, + }); - // remove the saved object reference - await deleteTransformRefs( - savedObjectsClient, - currentInstallation?.installed_es || [], - registryPackage.name, - previousInstalledTransformEsAssets.map((asset) => asset.id), - installedTransforms.map((installed) => installed.id) - ); + // remove the saved object reference + await deleteTransformRefs( + savedObjectsClient, + currentInstallation?.installed_es || [], + registryPackage.name, + previousInstalledTransformEsAssets.map((asset) => asset.id), + installedTransforms.map((installed) => installed.id) + ); + } + return installedTransforms; + } catch (err) { + logger.error(err); + throw err; } - return installedTransforms; }; const isTransform = (path: string) => { @@ -136,24 +147,31 @@ const isDatasetTransform = (path: string, datasetName: string) => { async function installTransform({ callCluster, transform, + logger, }: { callCluster: CallESAsCurrentUser; transform: TransformInstallation; + logger: Logger; }): Promise { - // defer validation on put if the source index is not available - await callCluster('transport.request', { - method: 'PUT', - path: `_transform/${transform.installationName}`, - query: 'defer_validation=true', - body: transform.content, - }); - - await callCluster('transport.request', { - method: 'POST', - path: `_transform/${transform.installationName}/_start`, - }); - - return { id: transform.installationName, type: ElasticsearchAssetType.transform }; + try { + // defer validation on put if the source index is not available + await callCluster('transport.request', { + method: 'PUT', + path: `/_transform/${transform.installationName}`, + query: 'defer_validation=true', + body: transform.content, + }); + + await callCluster('transport.request', { + method: 'POST', + path: `/_transform/${transform.installationName}/_start`, + }); + + return { id: transform.installationName, type: ElasticsearchAssetType.transform }; + } catch (err) { + logger.error(err); + throw err; + } } const getTransformNameForInstallation = ( diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts index 5c9d3e2846200..a527d05f1c49b 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts @@ -12,7 +12,7 @@ export const stopTransforms = async (transformIds: string[], callCluster: CallES for (const transformId of transformIds) { await callCluster('transport.request', { method: 'POST', - path: `_transform/${transformId}/_stop`, + path: `/_transform/${transformId}/_stop`, query: 'force=true', ignore: [404], }); @@ -29,7 +29,7 @@ export const deleteTransforms = async ( await callCluster('transport.request', { method: 'DELETE', query: 'force=true', - path: `_transform/${transformId}`, + path: `/_transform/${transformId}`, ignore: [404], }); }) diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts index 0b66077b8699a..bb506ecad0ade 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { loggingSystemMock } from '../../../../../../../../src/core/server/logging/logging_system.mock'; + jest.mock('../../packages/get', () => { return { getInstallation: jest.fn(), getInstallationObject: jest.fn() }; }); @@ -15,7 +18,12 @@ jest.mock('./common', () => { }); import { installTransformForDataset } from './install'; -import { ILegacyScopedClusterClient, SavedObject, SavedObjectsClientContract } from 'kibana/server'; +import { + ILegacyScopedClusterClient, + LoggerFactory, + SavedObject, + SavedObjectsClientContract, +} from 'kibana/server'; import { ElasticsearchAssetType, Installation, RegistryPackage } from '../../../../types'; import { getInstallation, getInstallationObject } from '../../packages'; import { getAsset } from './common'; @@ -25,6 +33,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/ describe('test transform install', () => { let legacyScopedClusterClient: jest.Mocked; let savedObjectsClient: jest.Mocked; + let logger: jest.Mocked; beforeEach(() => { legacyScopedClusterClient = { callAsInternalUser: jest.fn(), @@ -33,6 +42,7 @@ describe('test transform install', () => { (getInstallation as jest.MockedFunction).mockReset(); (getInstallationObject as jest.MockedFunction).mockReset(); savedObjectsClient = savedObjectsClientMock.create(); + logger = loggingSystemMock.create(); }); afterEach(() => { @@ -132,15 +142,15 @@ describe('test transform install', () => { 'endpoint-0.16.0-dev.0/dataset/metadata_current/elasticsearch/transform/default.json', ], legacyScopedClusterClient.callAsCurrentUser, - savedObjectsClient + savedObjectsClient, + logger.get('ingest') ); - expect(legacyScopedClusterClient.callAsCurrentUser.mock.calls).toEqual([ [ 'transport.request', { method: 'POST', - path: '_transform/metrics-endpoint.metadata_current-default-0.15.0-dev.0/_stop', + path: '/_transform/metrics-endpoint.metadata_current-default-0.15.0-dev.0/_stop', query: 'force=true', ignore: [404], }, @@ -150,7 +160,7 @@ describe('test transform install', () => { { method: 'DELETE', query: 'force=true', - path: '_transform/metrics-endpoint.metadata_current-default-0.15.0-dev.0', + path: '/_transform/metrics-endpoint.metadata_current-default-0.15.0-dev.0', ignore: [404], }, ], @@ -158,7 +168,7 @@ describe('test transform install', () => { 'transport.request', { method: 'PUT', - path: '_transform/metrics-endpoint.metadata-default-0.16.0-dev.0', + path: '/_transform/metrics-endpoint.metadata-default-0.16.0-dev.0', query: 'defer_validation=true', body: '{"content": "data"}', }, @@ -167,7 +177,7 @@ describe('test transform install', () => { 'transport.request', { method: 'PUT', - path: '_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0', + path: '/_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0', query: 'defer_validation=true', body: '{"content": "data"}', }, @@ -176,14 +186,14 @@ describe('test transform install', () => { 'transport.request', { method: 'POST', - path: '_transform/metrics-endpoint.metadata-default-0.16.0-dev.0/_start', + path: '/_transform/metrics-endpoint.metadata-default-0.16.0-dev.0/_start', }, ], [ 'transport.request', { method: 'POST', - path: '_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0/_start', + path: '/_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0/_start', }, ], ]); @@ -287,7 +297,8 @@ describe('test transform install', () => { } as unknown) as RegistryPackage, ['endpoint-0.16.0-dev.0/dataset/metadata_current/elasticsearch/transform/default.json'], legacyScopedClusterClient.callAsCurrentUser, - savedObjectsClient + savedObjectsClient, + logger.get('ingest') ); expect(legacyScopedClusterClient.callAsCurrentUser.mock.calls).toEqual([ @@ -295,7 +306,7 @@ describe('test transform install', () => { 'transport.request', { method: 'PUT', - path: '_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0', + path: '/_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0', query: 'defer_validation=true', body: '{"content": "data"}', }, @@ -304,7 +315,7 @@ describe('test transform install', () => { 'transport.request', { method: 'POST', - path: '_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0/_start', + path: '/_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0/_start', }, ], ]); @@ -384,26 +395,27 @@ describe('test transform install', () => { } as unknown) as RegistryPackage, [], legacyScopedClusterClient.callAsCurrentUser, - savedObjectsClient + savedObjectsClient, + logger.get('ingest') ); expect(legacyScopedClusterClient.callAsCurrentUser.mock.calls).toEqual([ [ 'transport.request', { - ignore: [404], method: 'POST', - path: '_transform/metrics-endpoint.metadata-current-default-0.15.0-dev.0/_stop', + path: '/_transform/metrics-endpoint.metadata-current-default-0.15.0-dev.0/_stop', query: 'force=true', + ignore: [404], }, ], [ 'transport.request', { - ignore: [404], method: 'DELETE', - path: '_transform/metrics-endpoint.metadata-current-default-0.15.0-dev.0', query: 'force=true', + path: '/_transform/metrics-endpoint.metadata-current-default-0.15.0-dev.0', + ignore: [404], }, ], ]); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 54b9c4d3fbb17..4179e82d6ad1d 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -36,6 +36,7 @@ import { deleteKibanaSavedObjectsAssets } from './remove'; import { PackageOutdatedError } from '../../../errors'; import { getPackageSavedObjects } from './get'; import { installTransformForDataset } from '../elasticsearch/transform/install'; +import { appContextService } from '../../app_context'; export async function installLatestPackage(options: { savedObjectsClient: SavedObjectsClientContract; @@ -196,7 +197,8 @@ export async function installPackage({ registryPackageInfo, paths, callCluster, - savedObjectsClient + savedObjectsClient, + appContextService.getLogger() ); // if this is an update or retrying an update, delete the previous version's pipelines diff --git a/x-pack/plugins/ingest_manager/server/services/index.ts b/x-pack/plugins/ingest_manager/server/services/index.ts index e768862d2dee1..5942277e90824 100644 --- a/x-pack/plugins/ingest_manager/server/services/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/index.ts @@ -5,7 +5,7 @@ */ import { SavedObjectsClientContract, KibanaRequest } from 'kibana/server'; -import { AgentStatus, Agent } from '../types'; +import { AgentStatus, Agent, EsAssetReference } from '../types'; import * as settingsService from './settings'; export { ESIndexPatternSavedObjectService } from './es_index_pattern'; @@ -22,6 +22,17 @@ export interface ESIndexPatternService { ): Promise; } +/** + * Service that provides exported function that return information about EPM packages + */ + +export interface PackageService { + getInstalledEsAssetReferences( + savedObjectsClient: SavedObjectsClientContract, + pkgName: string + ): Promise; +} + /** * A service that provides exported functions that return information about an Agent */ diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css index 12ac5b27c7a4a..35590df90fbb9 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css @@ -36,7 +36,7 @@ filter-bar, /* hide unusable controls */ discover-app .dscTimechart, discover-app .dscSidebar__container, -discover-app .kbnCollapsibleSidebar__collapseButton, +discover-app .dscCollapsibleSidebar__collapseButton, discover-app .discover-table-footer { display: none; } diff --git a/x-pack/plugins/reporting/server/lib/layouts/print.css b/x-pack/plugins/reporting/server/lib/layouts/print.css index 9b07e3c923138..3ff39974536d2 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print.css +++ b/x-pack/plugins/reporting/server/lib/layouts/print.css @@ -35,7 +35,7 @@ filter-bar, /* hide unusable controls */ discover-app .dscTimechart, discover-app .dscSidebar__container, -discover-app .kbnCollapsibleSidebar__collapseButton, +discover-app .dscCollapsibleSidebar__collapseButton, discover-app .discover-table-footer { display: none; } diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index cd59c2518794c..74ccf9105ba6b 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -8,6 +8,7 @@ export const eventsIndexPattern = 'logs-endpoint.events.*'; export const alertsIndexPattern = 'logs-endpoint.alerts-*'; export const metadataIndexPattern = 'metrics-endpoint.metadata-*'; export const metadataCurrentIndexPattern = 'metrics-endpoint.metadata_current-*'; +export const metadataTransformPrefix = 'metrics-endpoint.metadata-current-default'; export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index cc40225ec1a10..6afec75903477 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -297,6 +297,8 @@ export interface HostResultList { request_page_size: number; /* the page index requested */ request_page_index: number; + /* the version of the query strategy */ + query_strategy_version: MetadataQueryStrategyVersions; } /** @@ -504,9 +506,16 @@ export enum HostStatus { UNENROLLING = 'unenrolling', } +export enum MetadataQueryStrategyVersions { + VERSION_1 = 'v1', + VERSION_2 = 'v2', +} + export type HostInfo = Immutable<{ metadata: HostMetadata; host_status: HostStatus; + /* the version of the query strategy */ + query_strategy_version: MetadataQueryStrategyVersions; }>; export type HostMetadataDetails = Immutable<{ diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts index c5363a5ae9522..3b05afb975d47 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts @@ -11,6 +11,7 @@ import { HostPolicyResponse, HostResultList, HostStatus, + MetadataQueryStrategyVersions, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { @@ -49,6 +50,7 @@ export const mockEndpointResultList: (options?: { hosts.push({ metadata: generator.generateHostMetadata(), host_status: HostStatus.ERROR, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }); } const mock: HostResultList = { @@ -56,6 +58,7 @@ export const mockEndpointResultList: (options?: { total, request_page_size: requestPageSize, request_page_index: requestPageIndex, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; return mock; }; @@ -67,6 +70,7 @@ export const mockEndpointDetailsApiResult = (): HostInfo => { return { metadata: generator.generateHostMetadata(), host_status: HostStatus.ERROR, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; }; @@ -103,6 +107,7 @@ const endpointListApiPathHandlerMocks = ({ request_page_size: 10, request_page_index: 0, total: endpointsResults?.length || 0, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 2bdf17b806079..bd8344f41fe3a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -20,6 +20,7 @@ import { HostPolicyResponseActionStatus, HostPolicyResponseAppliedAction, HostStatus, + MetadataQueryStrategyVersions, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants'; @@ -131,6 +132,7 @@ describe('when on the list page', () => { hostListData[index] = { metadata: hostListData[index].metadata, host_status: status, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; } ); @@ -301,6 +303,8 @@ describe('when on the list page', () => { // eslint-disable-next-line @typescript-eslint/naming-convention host_status, metadata: { host, ...details }, + // eslint-disable-next-line @typescript-eslint/naming-convention + query_strategy_version, } = mockEndpointDetailsApiResult(); hostDetails = { @@ -312,6 +316,7 @@ describe('when on the list page', () => { id: '1', }, }, + query_strategy_version, }; agentId = hostDetails.metadata.elastic.agent.id; @@ -681,6 +686,7 @@ describe('when on the list page', () => { hostInfo = { host_status: hosts[0].host_status, metadata: hosts[0].metadata, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; const packagePolicy = docGenerator.generatePolicyPackagePolicy(); packagePolicy.id = hosts[0].metadata.Endpoint.policy.applied.id; diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 0ec0db9f32776..3fc41550a1fc4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -9,12 +9,64 @@ import { SavedObjectsServiceStart, SavedObjectsClientContract, } from 'src/core/server'; -import { AgentService, IngestManagerStartContract } from '../../../ingest_manager/server'; +import { + AgentService, + IngestManagerStartContract, + PackageService, +} from '../../../ingest_manager/server'; import { getPackagePolicyCreateCallback } from './ingest_integration'; import { ManifestManager } from './services/artifacts'; +import { MetadataQueryStrategy } from './types'; +import { MetadataQueryStrategyVersions } from '../../common/endpoint/types'; +import { + metadataQueryStrategyV1, + metadataQueryStrategyV2, +} from './routes/metadata/support/query_strategies'; +import { ElasticsearchAssetType } from '../../../ingest_manager/common/types/models'; +import { metadataTransformPrefix } from '../../common/endpoint/constants'; + +export interface MetadataService { + queryStrategy( + savedObjectsClient: SavedObjectsClientContract, + version?: MetadataQueryStrategyVersions + ): Promise; +} + +export const createMetadataService = (packageService: PackageService): MetadataService => { + return { + async queryStrategy( + savedObjectsClient: SavedObjectsClientContract, + version?: MetadataQueryStrategyVersions + ): Promise { + if (version === MetadataQueryStrategyVersions.VERSION_1) { + return metadataQueryStrategyV1(); + } + if (!packageService) { + throw new Error('package service is uninitialized'); + } + + if (version === MetadataQueryStrategyVersions.VERSION_2 || !version) { + const assets = await packageService.getInstalledEsAssetReferences( + savedObjectsClient, + 'endpoint' + ); + const expectedTransformAssets = assets.filter( + (ref) => + ref.type === ElasticsearchAssetType.transform && + ref.id.startsWith(metadataTransformPrefix) + ); + if (expectedTransformAssets && expectedTransformAssets.length === 1) { + return metadataQueryStrategyV2(); + } + return metadataQueryStrategyV1(); + } + return metadataQueryStrategyV1(); + }, + }; +}; export type EndpointAppContextServiceStartContract = Partial< - Pick + Pick > & { logger: Logger; manifestManager?: ManifestManager; @@ -30,11 +82,13 @@ export class EndpointAppContextService { private agentService: AgentService | undefined; private manifestManager: ManifestManager | undefined; private savedObjectsStart: SavedObjectsServiceStart | undefined; + private metadataService: MetadataService | undefined; public start(dependencies: EndpointAppContextServiceStartContract) { this.agentService = dependencies.agentService; this.manifestManager = dependencies.manifestManager; this.savedObjectsStart = dependencies.savedObjectsStart; + this.metadataService = createMetadataService(dependencies.packageService!); if (this.manifestManager && dependencies.registerIngestCallback) { dependencies.registerIngestCallback( @@ -50,6 +104,10 @@ export class EndpointAppContextService { return this.agentService; } + public getMetadataService(): MetadataService | undefined { + return this.metadataService; + } + public getManifestManager(): ManifestManager | undefined { return this.manifestManager; } diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index b5f35a198fa9e..9fd1fb26b1c58 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -11,6 +11,7 @@ import { AgentService, IngestManagerStartContract, ExternalCallback, + PackageService, } from '../../../ingest_manager/server'; import { createPackagePolicyServiceMock } from '../../../ingest_manager/server/mocks'; import { createMockConfig } from '../lib/detection_engine/routes/__mocks__'; @@ -58,6 +59,7 @@ export const createMockEndpointAppContextServiceStartContract = (): jest.Mocked< > => { return { agentService: createMockAgentService(), + packageService: createMockPackageService(), logger: loggingSystemMock.create().get('mock_endpoint_app_context'), savedObjectsStart: savedObjectsServiceMock.createStartContract(), manifestManager: getManifestManagerMock(), @@ -68,6 +70,16 @@ export const createMockEndpointAppContextServiceStartContract = (): jest.Mocked< }; }; +/** + * Create mock PackageService + */ + +export const createMockPackageService = (): jest.Mocked => { + return { + getInstalledEsAssetReferences: jest.fn(), + }; +}; + /** * Creates a mock AgentService */ @@ -95,6 +107,7 @@ export const createMockIngestManagerStartContract = ( getESIndexPattern: jest.fn().mockResolvedValue(indexPattern), }, agentService: createMockAgentService(), + packageService: createMockPackageService(), registerExternalCallback: jest.fn((...args: ExternalCallback) => {}), packagePolicyService: createPackagePolicyServiceMock(), }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts new file mode 100644 index 0000000000000..cc371f9120ba0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -0,0 +1,290 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 Boom from 'boom'; +import { RequestHandlerContext, Logger, RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + HostInfo, + HostMetadata, + HostResultList, + HostStatus, + MetadataQueryStrategyVersions, +} from '../../../../common/endpoint/types'; +import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders'; +import { Agent, AgentStatus } from '../../../../../ingest_manager/common/types/models'; +import { EndpointAppContext, HostListQueryResult } from '../../types'; +import { GetMetadataListRequestSchema, GetMetadataRequestSchema } from './index'; +import { findAllUnenrolledAgentIds } from './support/unenroll'; +import { findAgentIDsByStatus } from './support/agent_status'; +import { EndpointAppContextService } from '../../endpoint_app_context_services'; + +export interface MetadataRequestContext { + endpointAppContextService: EndpointAppContextService; + logger: Logger; + requestHandlerContext: RequestHandlerContext; +} + +const HOST_STATUS_MAPPING = new Map([ + ['online', HostStatus.ONLINE], + ['offline', HostStatus.OFFLINE], + ['unenrolling', HostStatus.UNENROLLING], +]); + +/** + * 00000000-0000-0000-0000-000000000000 is initial Elastic Agent id sent by Endpoint before policy is configured + * 11111111-1111-1111-1111-111111111111 is Elastic Agent id sent by Endpoint when policy does not contain an id + */ + +const IGNORED_ELASTIC_AGENT_IDS = [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', +]; + +export const getLogger = (endpointAppContext: EndpointAppContext): Logger => { + return endpointAppContext.logFactory.get('metadata'); +}; + +export const getMetadataListRequestHandler = function ( + endpointAppContext: EndpointAppContext, + logger: Logger, + queryStrategyVersion?: MetadataQueryStrategyVersions +): RequestHandler> { + return async (context, request, response) => { + try { + const agentService = endpointAppContext.service.getAgentService(); + if (agentService === undefined) { + throw new Error('agentService not available'); + } + + const metadataRequestContext: MetadataRequestContext = { + endpointAppContextService: endpointAppContext.service, + logger, + requestHandlerContext: context, + }; + + const unenrolledAgentIds = await findAllUnenrolledAgentIds( + agentService, + context.core.savedObjects.client + ); + + const statusIDs = request?.body?.filters?.host_status?.length + ? await findAgentIDsByStatus( + agentService, + context.core.savedObjects.client, + request.body?.filters?.host_status + ) + : undefined; + + const queryStrategy = await endpointAppContext.service + ?.getMetadataService() + ?.queryStrategy(context.core.savedObjects.client, queryStrategyVersion); + + const queryParams = await kibanaRequestToMetadataListESQuery( + request, + endpointAppContext, + queryStrategy!, + { + unenrolledAgentIds: unenrolledAgentIds.concat(IGNORED_ELASTIC_AGENT_IDS), + statusAgentIDs: statusIDs, + } + ); + + const hostListQueryResult = queryStrategy!.queryResponseToHostListResult( + await context.core.elasticsearch.legacy.client.callAsCurrentUser('search', queryParams) + ); + return response.ok({ + body: await mapToHostResultList(queryParams, hostListQueryResult, metadataRequestContext), + }); + } catch (err) { + logger.warn(JSON.stringify(err, null, 2)); + return response.internalError({ body: err }); + } + }; +}; + +export const getMetadataRequestHandler = function ( + endpointAppContext: EndpointAppContext, + logger: Logger, + queryStrategyVersion?: MetadataQueryStrategyVersions +): RequestHandler, undefined, undefined> { + return async (context, request, response) => { + const agentService = endpointAppContext.service.getAgentService(); + if (agentService === undefined) { + return response.internalError({ body: 'agentService not available' }); + } + + const metadataRequestContext: MetadataRequestContext = { + endpointAppContextService: endpointAppContext.service, + logger, + requestHandlerContext: context, + }; + + try { + const doc = await getHostData( + metadataRequestContext, + request?.params?.id, + queryStrategyVersion + ); + if (doc) { + return response.ok({ body: doc }); + } + return response.notFound({ body: 'Endpoint Not Found' }); + } catch (err) { + logger.warn(JSON.stringify(err, null, 2)); + if (err.isBoom) { + return response.customError({ + statusCode: err.output.statusCode, + body: { message: err.message }, + }); + } + return response.internalError({ body: err }); + } + }; +}; + +export async function getHostData( + metadataRequestContext: MetadataRequestContext, + id: string, + queryStrategyVersion?: MetadataQueryStrategyVersions +): Promise { + const queryStrategy = await metadataRequestContext.endpointAppContextService + ?.getMetadataService() + ?.queryStrategy( + metadataRequestContext.requestHandlerContext.core.savedObjects.client, + queryStrategyVersion + ); + + const query = getESQueryHostMetadataByID(id, queryStrategy!); + const hostResult = queryStrategy!.queryResponseToHostResult( + await metadataRequestContext.requestHandlerContext.core.elasticsearch.legacy.client.callAsCurrentUser( + 'search', + query + ) + ); + const hostMetadata = hostResult.result; + if (!hostMetadata) { + return undefined; + } + + const agent = await findAgent(metadataRequestContext, hostMetadata); + + if (agent && !agent.active) { + throw Boom.badRequest('the requested endpoint is unenrolled'); + } + + const metadata = await enrichHostMetadata( + hostMetadata, + metadataRequestContext, + hostResult.queryStrategyVersion + ); + return { ...metadata, query_strategy_version: hostResult.queryStrategyVersion }; +} + +async function findAgent( + metadataRequestContext: MetadataRequestContext, + hostMetadata: HostMetadata +): Promise { + try { + return await metadataRequestContext.endpointAppContextService + ?.getAgentService() + ?.getAgent( + metadataRequestContext.requestHandlerContext.core.savedObjects.client, + hostMetadata.elastic.agent.id + ); + } catch (e) { + if ( + metadataRequestContext.requestHandlerContext.core.savedObjects.client.errors.isNotFoundError( + e + ) + ) { + metadataRequestContext.logger.warn( + `agent with id ${hostMetadata.elastic.agent.id} not found` + ); + return undefined; + } else { + throw e; + } + } +} + +export async function mapToHostResultList( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryParams: Record, + hostListQueryResult: HostListQueryResult, + metadataRequestContext: MetadataRequestContext +): Promise { + const totalNumberOfHosts = hostListQueryResult.resultLength; + if (hostListQueryResult.resultList.length > 0) { + return { + request_page_size: queryParams.size, + request_page_index: queryParams.from, + hosts: await Promise.all( + hostListQueryResult.resultList.map(async (entry) => + enrichHostMetadata( + entry, + metadataRequestContext, + hostListQueryResult.queryStrategyVersion + ) + ) + ), + total: totalNumberOfHosts, + query_strategy_version: hostListQueryResult.queryStrategyVersion, + }; + } else { + return { + request_page_size: queryParams.size, + request_page_index: queryParams.from, + total: totalNumberOfHosts, + hosts: [], + query_strategy_version: hostListQueryResult.queryStrategyVersion, + }; + } +} + +async function enrichHostMetadata( + hostMetadata: HostMetadata, + metadataRequestContext: MetadataRequestContext, + metadataQueryStrategyVersion: MetadataQueryStrategyVersions +): Promise { + let hostStatus = HostStatus.ERROR; + let elasticAgentId = hostMetadata?.elastic?.agent?.id; + const log = metadataRequestContext.logger; + try { + /** + * Get agent status by elastic agent id if available or use the host id. + */ + + if (!elasticAgentId) { + elasticAgentId = hostMetadata.host.id; + log.warn(`Missing elastic agent id, using host id instead ${elasticAgentId}`); + } + + const status = await metadataRequestContext.endpointAppContextService + ?.getAgentService() + ?.getAgentStatusById( + metadataRequestContext.requestHandlerContext.core.savedObjects.client, + elasticAgentId + ); + hostStatus = HOST_STATUS_MAPPING.get(status!) || HostStatus.ERROR; + } catch (e) { + if ( + metadataRequestContext.requestHandlerContext.core.savedObjects.client.errors.isNotFoundError( + e + ) + ) { + log.warn(`agent with id ${elasticAgentId} not found`); + } else { + log.error(e); + throw e; + } + } + return { + metadata: hostMetadata, + host_status: hostStatus, + query_strategy_version: metadataQueryStrategyVersion, + }; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index 144c536b4e45f..bfb2a6a828e68 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -4,51 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter, Logger, RequestHandlerContext } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; +import { IRouter } from 'kibana/server'; import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; -import { metadataCurrentIndexPattern } from '../../../../common/endpoint/constants'; -import { getESQueryHostMetadataByID, kibanaRequestToMetadataListESQuery } from './query_builders'; -import { - HostInfo, - HostMetadata, - HostMetadataDetails, - HostResultList, - HostStatus, -} from '../../../../common/endpoint/types'; +import { HostStatus, MetadataQueryStrategyVersions } from '../../../../common/endpoint/types'; import { EndpointAppContext } from '../../types'; -import { AgentService } from '../../../../../ingest_manager/server'; -import { Agent, AgentStatus } from '../../../../../ingest_manager/common/types/models'; -import { findAllUnenrolledAgentIds } from './support/unenroll'; -import { findAgentIDsByStatus } from './support/agent_status'; +import { getLogger, getMetadataListRequestHandler, getMetadataRequestHandler } from './handlers'; -interface MetadataRequestContext { - agentService: AgentService; - logger: Logger; - requestHandlerContext: RequestHandlerContext; -} - -const HOST_STATUS_MAPPING = new Map([ - ['online', HostStatus.ONLINE], - ['offline', HostStatus.OFFLINE], - ['unenrolling', HostStatus.UNENROLLING], -]); - -/** - * 00000000-0000-0000-0000-000000000000 is initial Elastic Agent id sent by Endpoint before policy is configured - * 11111111-1111-1111-1111-111111111111 is Elastic Agent id sent by Endpoint when policy does not contain an id - */ - -const IGNORED_ELASTIC_AGENT_IDS = [ - '00000000-0000-0000-0000-000000000000', - '11111111-1111-1111-1111-111111111111', -]; - -const getLogger = (endpointAppContext: EndpointAppContext): Logger => { - return endpointAppContext.logFactory.get('metadata'); -}; +export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; +export const METADATA_REQUEST_V1_ROUTE = `${BASE_ENDPOINT_ROUTE}/v1/metadata`; +export const GET_METADATA_REQUEST_V1_ROUTE = `${METADATA_REQUEST_V1_ROUTE}/{id}`; +export const METADATA_REQUEST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; +export const GET_METADATA_REQUEST_ROUTE = `${METADATA_REQUEST_ROUTE}/{id}`; /* Filters that can be applied to the endpoint fetch route */ export const endpointFilters = schema.object({ @@ -65,241 +32,73 @@ export const endpointFilters = schema.object({ ), }); +export const GetMetadataRequestSchema = { + params: schema.object({ id: schema.string() }), +}; + +export const GetMetadataListRequestSchema = { + body: schema.nullable( + schema.object({ + paging_properties: schema.nullable( + schema.arrayOf( + schema.oneOf([ + /** + * the number of results to return for this request per page + */ + schema.object({ + page_size: schema.number({ defaultValue: 10, min: 1, max: 10000 }), + }), + /** + * the zero based page index of the the total number of pages of page size + */ + schema.object({ page_index: schema.number({ defaultValue: 0, min: 0 }) }), + ]) + ) + ), + filters: endpointFilters, + }) + ), +}; + export function registerEndpointRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { const logger = getLogger(endpointAppContext); router.post( { - path: '/api/endpoint/metadata', - validate: { - body: schema.nullable( - schema.object({ - paging_properties: schema.nullable( - schema.arrayOf( - schema.oneOf([ - /** - * the number of results to return for this request per page - */ - schema.object({ - page_size: schema.number({ defaultValue: 10, min: 1, max: 10000 }), - }), - /** - * the zero based page index of the the total number of pages of page size - */ - schema.object({ page_index: schema.number({ defaultValue: 0, min: 0 }) }), - ]) - ) - ), - filters: endpointFilters, - }) - ), - }, + path: `${METADATA_REQUEST_V1_ROUTE}`, + validate: GetMetadataListRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, - async (context, req, res) => { - try { - const agentService = endpointAppContext.service.getAgentService(); - if (agentService === undefined) { - throw new Error('agentService not available'); - } - - const metadataRequestContext: MetadataRequestContext = { - agentService, - logger, - requestHandlerContext: context, - }; - - const unenrolledAgentIds = await findAllUnenrolledAgentIds( - agentService, - context.core.savedObjects.client - ); - - const statusIDs = req.body?.filters?.host_status?.length - ? await findAgentIDsByStatus( - agentService, - context.core.savedObjects.client, - req.body?.filters?.host_status - ) - : undefined; - - const queryParams = await kibanaRequestToMetadataListESQuery( - req, - endpointAppContext, - metadataCurrentIndexPattern, - { - unenrolledAgentIds: unenrolledAgentIds.concat(IGNORED_ELASTIC_AGENT_IDS), - statusAgentIDs: statusIDs, - } - ); - - const response = (await context.core.elasticsearch.legacy.client.callAsCurrentUser( - 'search', - queryParams - )) as SearchResponse; + getMetadataListRequestHandler( + endpointAppContext, + logger, + MetadataQueryStrategyVersions.VERSION_1 + ) + ); - return res.ok({ - body: await mapToHostResultList(queryParams, response, metadataRequestContext), - }); - } catch (err) { - logger.warn(JSON.stringify(err, null, 2)); - return res.internalError({ body: err }); - } - } + router.post( + { + path: `${METADATA_REQUEST_ROUTE}`, + validate: GetMetadataListRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + getMetadataListRequestHandler(endpointAppContext, logger) ); router.get( { - path: '/api/endpoint/metadata/{id}', - validate: { - params: schema.object({ id: schema.string() }), - }, + path: `${GET_METADATA_REQUEST_V1_ROUTE}`, + validate: GetMetadataRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, - async (context, req, res) => { - const agentService = endpointAppContext.service.getAgentService(); - if (agentService === undefined) { - return res.internalError({ body: 'agentService not available' }); - } - - const metadataRequestContext: MetadataRequestContext = { - agentService, - logger, - requestHandlerContext: context, - }; - - try { - const doc = await getHostData(metadataRequestContext, req.params.id); - if (doc) { - return res.ok({ body: doc }); - } - return res.notFound({ body: 'Endpoint Not Found' }); - } catch (err) { - logger.warn(JSON.stringify(err, null, 2)); - if (err.isBoom) { - return res.customError({ - statusCode: err.output.statusCode, - body: { message: err.message }, - }); - } - return res.internalError({ body: err }); - } - } + getMetadataRequestHandler(endpointAppContext, logger, MetadataQueryStrategyVersions.VERSION_1) ); -} - -export async function getHostData( - metadataRequestContext: MetadataRequestContext, - id: string -): Promise { - const query = getESQueryHostMetadataByID(id, metadataCurrentIndexPattern); - const response = (await metadataRequestContext.requestHandlerContext.core.elasticsearch.legacy.client.callAsCurrentUser( - 'search', - query - )) as SearchResponse; - - if (response.hits.hits.length === 0) { - return undefined; - } - - const hostMetadata: HostMetadata = response.hits.hits[0]._source.HostDetails; - const agent = await findAgent(metadataRequestContext, hostMetadata); - - if (agent && !agent.active) { - throw Boom.badRequest('the requested endpoint is unenrolled'); - } - - return enrichHostMetadata(hostMetadata, metadataRequestContext); -} - -async function findAgent( - metadataRequestContext: MetadataRequestContext, - hostMetadata: HostMetadata -): Promise { - try { - return await metadataRequestContext.agentService.getAgent( - metadataRequestContext.requestHandlerContext.core.savedObjects.client, - hostMetadata.elastic.agent.id - ); - } catch (e) { - if ( - metadataRequestContext.requestHandlerContext.core.savedObjects.client.errors.isNotFoundError( - e - ) - ) { - metadataRequestContext.logger.warn( - `agent with id ${hostMetadata.elastic.agent.id} not found` - ); - return undefined; - } else { - throw e; - } - } -} -async function mapToHostResultList( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - queryParams: Record, - searchResponse: SearchResponse, - metadataRequestContext: MetadataRequestContext -): Promise { - const totalNumberOfHosts = - ((searchResponse.hits?.total as unknown) as { value: number; relation: string }).value || 0; - if (searchResponse.hits.hits.length > 0) { - return { - request_page_size: queryParams.size, - request_page_index: queryParams.from, - hosts: await Promise.all( - searchResponse.hits.hits.map(async (entry) => - enrichHostMetadata(entry._source.HostDetails, metadataRequestContext) - ) - ), - total: totalNumberOfHosts, - }; - } else { - return { - request_page_size: queryParams.size, - request_page_index: queryParams.from, - total: totalNumberOfHosts, - hosts: [], - }; - } -} - -async function enrichHostMetadata( - hostMetadata: HostMetadata, - metadataRequestContext: MetadataRequestContext -): Promise { - let hostStatus = HostStatus.ERROR; - let elasticAgentId = hostMetadata?.elastic?.agent?.id; - const log = metadataRequestContext.logger; - try { - /** - * Get agent status by elastic agent id if available or use the host id. - */ - - if (!elasticAgentId) { - elasticAgentId = hostMetadata.host.id; - log.warn(`Missing elastic agent id, using host id instead ${elasticAgentId}`); - } - - const status = await metadataRequestContext.agentService.getAgentStatusById( - metadataRequestContext.requestHandlerContext.core.savedObjects.client, - elasticAgentId - ); - hostStatus = HOST_STATUS_MAPPING.get(status) || HostStatus.ERROR; - } catch (e) { - if ( - metadataRequestContext.requestHandlerContext.core.savedObjects.client.errors.isNotFoundError( - e - ) - ) { - log.warn(`agent with id ${elasticAgentId} not found`); - } else { - log.error(e); - throw e; - } - } - return { - metadata: hostMetadata, - host_status: hostStatus, - }; + router.get( + { + path: `${GET_METADATA_REQUEST_ROUTE}`, + validate: GetMetadataRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + getMetadataRequestHandler(endpointAppContext, logger) + ); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index f784941f3539a..299939eb92444 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -22,21 +22,26 @@ import { } from '../../../../../../../src/core/server/mocks'; import { HostInfo, - HostMetadata, - HostMetadataDetails, HostResultList, HostStatus, + MetadataQueryStrategyVersions, } from '../../../../common/endpoint/types'; -import { SearchResponse } from 'elasticsearch'; -import { registerEndpointRoutes, endpointFilters } from './index'; +import { registerEndpointRoutes, METADATA_REQUEST_ROUTE } from './index'; import { createMockEndpointAppContextServiceStartContract, + createMockPackageService, createRouteHandlerContext, } from '../../mocks'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; -import { Agent } from '../../../../../ingest_manager/common/types/models'; +import { + Agent, + ElasticsearchAssetType, + EsAssetReference, +} from '../../../../../ingest_manager/common/types/models'; +import { createV1SearchResponse, createV2SearchResponse } from './support/test_support'; +import { PackageService } from '../../../../../ingest_manager/server/services'; describe('test endpoint route', () => { let routerMock: jest.Mocked; @@ -44,6 +49,7 @@ describe('test endpoint route', () => { let mockClusterClient: jest.Mocked; let mockScopedClient: jest.Mocked; let mockSavedObjectClient: jest.Mocked; + let mockPackageService: jest.Mocked; // eslint-disable-next-line @typescript-eslint/no-explicit-any let routeHandler: RequestHandler; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -61,195 +67,45 @@ describe('test endpoint route', () => { }; beforeEach(() => { + mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient() as jest.Mocked< ILegacyClusterClient >; - mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockSavedObjectClient = savedObjectsClientMock.create(); mockClusterClient.asScoped.mockReturnValue(mockScopedClient); routerMock = httpServiceMock.createRouter(); mockResponse = httpServerMock.createResponseFactory(); - endpointAppContextService = new EndpointAppContextService(); - const startContract = createMockEndpointAppContextServiceStartContract(); - endpointAppContextService.start(startContract); - mockAgentService = startContract.agentService!; - - registerEndpointRoutes(routerMock, { - logFactory: loggingSystemMock.create(), - service: endpointAppContextService, - config: () => Promise.resolve(createMockConfig()), - }); - }); - - afterEach(() => endpointAppContextService.stop()); - - it('test find the latest of all endpoints', async () => { - const mockRequest = httpServerMock.createKibanaRequest({}); - const response = createSearchResponse(new EndpointDocGenerator().generateHostMetadata()); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); - [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith('/api/endpoint/metadata') - )!; - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - await routeHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); - - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'] }); - expect(mockResponse.ok).toBeCalled(); - const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; - expect(endpointResultList.hosts.length).toEqual(1); - expect(endpointResultList.total).toEqual(1); - expect(endpointResultList.request_page_index).toEqual(0); - expect(endpointResultList.request_page_size).toEqual(10); - }); - - it('test find the latest of all endpoints with paging properties', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ - body: { - paging_properties: [ - { - page_size: 10, - }, - { - page_index: 1, - }, - ], - }, - }); - - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) - ); - [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith('/api/endpoint/metadata') - )!; - - await routeHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); - - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ - bool: { - must_not: { - terms: { - 'HostDetails.elastic.agent.id': [ - '00000000-0000-0000-0000-000000000000', - '11111111-1111-1111-1111-111111111111', - ], - }, - }, - }, - }); - expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'] }); - expect(mockResponse.ok).toBeCalled(); - const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; - expect(endpointResultList.hosts.length).toEqual(1); - expect(endpointResultList.total).toEqual(1); - expect(endpointResultList.request_page_index).toEqual(10); - expect(endpointResultList.request_page_size).toEqual(10); }); - it('test find the latest of all endpoints with paging and filters properties', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ - body: { - paging_properties: [ - { - page_size: 10, - }, - { - page_index: 1, - }, - ], - - filters: { kql: 'not host.ip:10.140.73.246' }, - }, - }); + describe('with no transform package', () => { + beforeEach(() => { + endpointAppContextService = new EndpointAppContextService(); + const startContract = createMockEndpointAppContextServiceStartContract(); + mockPackageService = createMockPackageService(); + mockPackageService.getInstalledEsAssetReferences.mockReturnValue( + Promise.resolve(([] as unknown) as EsAssetReference[]) + ); + endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); + mockAgentService = startContract.agentService!; - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse(new EndpointDocGenerator().generateHostMetadata())) - ); - [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith('/api/endpoint/metadata') - )!; - - await routeHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); - - expect(mockScopedClient.callAsCurrentUser).toBeCalled(); - expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ - bool: { - must: [ - { - bool: { - must_not: { - terms: { - 'HostDetails.elastic.agent.id': [ - '00000000-0000-0000-0000-000000000000', - '11111111-1111-1111-1111-111111111111', - ], - }, - }, - }, - }, - { - bool: { - must_not: { - bool: { - should: [ - { - match: { - 'host.ip': '10.140.73.246', - }, - }, - ], - minimum_should_match: 1, - }, - }, - }, - }, - ], - }, + registerEndpointRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + }); }); - expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'] }); - expect(mockResponse.ok).toBeCalled(); - const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; - expect(endpointResultList.hosts.length).toEqual(1); - expect(endpointResultList.total).toEqual(1); - expect(endpointResultList.request_page_index).toEqual(10); - expect(endpointResultList.request_page_size).toEqual(10); - }); - - describe('Endpoint Details route', () => { - it('should return 404 on no results', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'BADID' } }); - - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => - Promise.resolve(createSearchResponse()) - ); - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.getAgent = jest.fn().mockReturnValue(({ - active: true, - } as unknown) as Agent); + afterEach(() => endpointAppContextService.stop()); - [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith('/api/endpoint/metadata') + it('test find the latest of all endpoints', async () => { + const mockRequest = httpServerMock.createKibanaRequest({}); + const response = createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_ROUTE}`) )!; + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); await routeHandler( createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), mockRequest, @@ -261,13 +117,19 @@ describe('test endpoint route', () => { authRequired: true, tags: ['access:securitySolution'], }); - expect(mockResponse.notFound).toBeCalled(); - const message = mockResponse.notFound.mock.calls[0][0]?.body; - expect(message).toEqual('Endpoint Not Found'); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(0); + expect(endpointResultList.request_page_size).toEqual(10); + expect(endpointResultList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_1 + ); }); it('should return a single endpoint with status online', async () => { - const response = createSearchResponse(new EndpointDocGenerator().generateHostMetadata()); + const response = createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()); const mockRequest = httpServerMock.createKibanaRequest({ params: { id: response.hits.hits[0]._id }, }); @@ -279,7 +141,7 @@ describe('test endpoint route', () => { mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith('/api/endpoint/metadata') + path.startsWith(`${METADATA_REQUEST_ROUTE}`) )!; await routeHandler( @@ -297,29 +159,48 @@ describe('test endpoint route', () => { const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; expect(result).toHaveProperty('metadata.Endpoint'); expect(result.host_status).toEqual(HostStatus.ONLINE); + expect(result.query_strategy_version).toEqual(MetadataQueryStrategyVersions.VERSION_1); }); + }); - it('should return a single endpoint with status error when AgentService throw 404', async () => { - const response = createSearchResponse(new EndpointDocGenerator().generateHostMetadata()); - - const mockRequest = httpServerMock.createKibanaRequest({ - params: { id: response.hits.hits[0]._id }, - }); + describe('with new transform package', () => { + beforeEach(() => { + endpointAppContextService = new EndpointAppContextService(); + const startContract = createMockEndpointAppContextServiceStartContract(); + mockPackageService = createMockPackageService(); + mockPackageService.getInstalledEsAssetReferences.mockReturnValue( + Promise.resolve([ + { + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: 'metrics-endpoint.metadata-current-default-0.16.0-dev.0', + type: ElasticsearchAssetType.transform, + }, + ]) + ); + endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); + mockAgentService = startContract.agentService!; - mockAgentService.getAgentStatusById = jest.fn().mockImplementation(() => { - SavedObjectsErrorHelpers.createGenericNotFoundError(); + registerEndpointRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), }); + }); - mockAgentService.getAgent = jest.fn().mockImplementation(() => { - SavedObjectsErrorHelpers.createGenericNotFoundError(); - }); + afterEach(() => endpointAppContextService.stop()); + it('test find the latest of all endpoints', async () => { + const mockRequest = httpServerMock.createKibanaRequest({}); + const response = createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata()); mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); - - [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith('/api/endpoint/metadata') + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_ROUTE}`) )!; - + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); await routeHandler( createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), mockRequest, @@ -332,25 +213,37 @@ describe('test endpoint route', () => { tags: ['access:securitySolution'], }); expect(mockResponse.ok).toBeCalled(); - const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; - expect(result.host_status).toEqual(HostStatus.ERROR); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(0); + expect(endpointResultList.request_page_size).toEqual(10); + expect(endpointResultList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_2 + ); }); - it('should return a single endpoint with status error when status is not offline, online or enrolling', async () => { - const response = createSearchResponse(new EndpointDocGenerator().generateHostMetadata()); - + it('test find the latest of all endpoints with paging properties', async () => { const mockRequest = httpServerMock.createKibanaRequest({ - params: { id: response.hits.hits[0]._id }, + body: { + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 1, + }, + ], + }, }); - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('warning'); - mockAgentService.getAgent = jest.fn().mockReturnValue(({ - active: true, - } as unknown) as Agent); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); - - [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith('/api/endpoint/metadata') + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata())) + ); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_ROUTE}`) )!; await routeHandler( @@ -360,28 +253,56 @@ describe('test endpoint route', () => { ); expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ + bool: { + must_not: { + terms: { + 'HostDetails.elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + }, + }); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], }); expect(mockResponse.ok).toBeCalled(); - const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; - expect(result.host_status).toEqual(HostStatus.ERROR); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(10); + expect(endpointResultList.request_page_size).toEqual(10); + expect(endpointResultList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_2 + ); }); - it('should throw error when endpoint agent is not active', async () => { - const response = createSearchResponse(new EndpointDocGenerator().generateHostMetadata()); - + it('test find the latest of all endpoints with paging and filters properties', async () => { const mockRequest = httpServerMock.createKibanaRequest({ - params: { id: response.hits.hits[0]._id }, + body: { + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 1, + }, + ], + + filters: { kql: 'not host.ip:10.140.73.246' }, + }, }); - mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); - mockAgentService.getAgent = jest.fn().mockReturnValue(({ - active: false, - } as unknown) as Agent); - [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith('/api/endpoint/metadata') + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata())) + ); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_ROUTE}`) )!; await routeHandler( @@ -390,93 +311,216 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockResponse.customError).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ + bool: { + must: [ + { + bool: { + must_not: { + terms: { + 'HostDetails.elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(10); + expect(endpointResultList.request_page_size).toEqual(10); + expect(endpointResultList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_2 + ); }); - }); -}); -describe('Filters Schema Test', () => { - it('accepts a single host status', () => { - expect( - endpointFilters.validate({ - host_status: ['error'], - }) - ).toBeTruthy(); - }); - - it('accepts multiple host status filters', () => { - expect( - endpointFilters.validate({ - host_status: ['offline', 'unenrolling'], - }) - ).toBeTruthy(); - }); + describe('Endpoint Details route', () => { + it('should return 404 on no results', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'BADID' } }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createV2SearchResponse()) + ); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.getAgent = jest.fn().mockReturnValue(({ + active: true, + } as unknown) as Agent); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_ROUTE}`) + )!; + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.notFound).toBeCalled(); + const message = mockResponse.notFound.mock.calls[0][0]?.body; + expect(message).toEqual('Endpoint Not Found'); + }); - it('rejects invalid statuses', () => { - expect(() => - endpointFilters.validate({ - host_status: ['foobar'], - }) - ).toThrowError(); - }); + it('should return a single endpoint with status online', async () => { + const response = createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('online'); + mockAgentService.getAgent = jest.fn().mockReturnValue(({ + active: true, + } as unknown) as Agent); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; + expect(result).toHaveProperty('metadata.Endpoint'); + expect(result.host_status).toEqual(HostStatus.ONLINE); + expect(result.query_strategy_version).toEqual(MetadataQueryStrategyVersions.VERSION_2); + }); - it('accepts a KQL string', () => { - expect( - endpointFilters.validate({ - kql: 'whatever.field', - }) - ).toBeTruthy(); - }); + it('should return a single endpoint with status error when AgentService throw 404', async () => { + const response = createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + + mockAgentService.getAgentStatusById = jest.fn().mockImplementation(() => { + SavedObjectsErrorHelpers.createGenericNotFoundError(); + }); + + mockAgentService.getAgent = jest.fn().mockImplementation(() => { + SavedObjectsErrorHelpers.createGenericNotFoundError(); + }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; + expect(result.host_status).toEqual(HostStatus.ERROR); + }); - it('accepts KQL + status', () => { - expect( - endpointFilters.validate({ - kql: 'thing.var', - host_status: ['online'], - }) - ).toBeTruthy(); - }); + it('should return a single endpoint with status error when status is not offline, online or enrolling', async () => { + const response = createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('warning'); + mockAgentService.getAgent = jest.fn().mockReturnValue(({ + active: true, + } as unknown) as Agent); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; + expect(result.host_status).toEqual(HostStatus.ERROR); + }); - it('accepts no filters', () => { - expect(endpointFilters.validate({})).toBeTruthy(); + it('should throw error when endpoint agent is not active', async () => { + const response = createV2SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + mockAgentService.getAgent = jest.fn().mockReturnValue(({ + active: false, + } as unknown) as Agent); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toBeCalled(); + }); + }); }); }); - -function createSearchResponse(hostMetadata?: HostMetadata): SearchResponse { - return ({ - took: 15, - timed_out: false, - _shards: { - total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - hits: { - total: { - value: 1, - relation: 'eq', - }, - max_score: null, - hits: hostMetadata - ? [ - { - _index: 'metrics-endpoint.metadata-default', - _id: '8FhM0HEBYyRTvb6lOQnw', - _score: null, - _source: { - agent: { - id: '1e3472bb-5c20-4946-b469-b5af1a809e4f', - }, - HostDetails: { - ...hostMetadata, - }, - }, - sort: [1588337587997], - }, - ] - : [], - }, - } as unknown) as SearchResponse; -} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts new file mode 100644 index 0000000000000..568917c804733 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata_v1.test.ts @@ -0,0 +1,412 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { + ILegacyClusterClient, + IRouter, + ILegacyScopedClusterClient, + KibanaResponseFactory, + RequestHandler, + RouteConfig, + SavedObjectsClientContract, +} from 'kibana/server'; +import { SavedObjectsErrorHelpers } from '../../../../../../../src/core/server/'; +import { + elasticsearchServiceMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from '../../../../../../../src/core/server/mocks'; +import { + HostInfo, + HostResultList, + HostStatus, + MetadataQueryStrategyVersions, +} from '../../../../common/endpoint/types'; +import { registerEndpointRoutes, METADATA_REQUEST_V1_ROUTE } from './index'; +import { + createMockEndpointAppContextServiceStartContract, + createMockPackageService, + createRouteHandlerContext, +} from '../../mocks'; +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { Agent, EsAssetReference } from '../../../../../ingest_manager/common/types/models'; +import { createV1SearchResponse } from './support/test_support'; +import { PackageService } from '../../../../../ingest_manager/server/services'; + +describe('test endpoint route v1', () => { + let routerMock: jest.Mocked; + let mockResponse: jest.Mocked; + let mockClusterClient: jest.Mocked; + let mockScopedClient: jest.Mocked; + let mockSavedObjectClient: jest.Mocked; + let mockPackageService: jest.Mocked; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let routeHandler: RequestHandler; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let routeConfig: RouteConfig; + // tests assume that ingestManager is enabled, and thus agentService is available + let mockAgentService: Required< + ReturnType + >['agentService']; + let endpointAppContextService: EndpointAppContextService; + const noUnenrolledAgent = { + agents: [], + total: 0, + page: 1, + perPage: 1, + }; + + beforeEach(() => { + mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient() as jest.Mocked< + ILegacyClusterClient + >; + mockScopedClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockSavedObjectClient = savedObjectsClientMock.create(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClient); + routerMock = httpServiceMock.createRouter(); + mockResponse = httpServerMock.createResponseFactory(); + endpointAppContextService = new EndpointAppContextService(); + mockPackageService = createMockPackageService(); + mockPackageService.getInstalledEsAssetReferences.mockReturnValue( + Promise.resolve(([] as unknown) as EsAssetReference[]) + ); + const startContract = createMockEndpointAppContextServiceStartContract(); + endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); + mockAgentService = startContract.agentService!; + + registerEndpointRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + }); + }); + + afterEach(() => endpointAppContextService.stop()); + + it('test find the latest of all endpoints', async () => { + const mockRequest = httpServerMock.createKibanaRequest({}); + const response = createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) + )!; + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'] }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(0); + expect(endpointResultList.request_page_size).toEqual(10); + expect(endpointResultList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_1 + ); + }); + + it('test find the latest of all endpoints with paging properties', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 1, + }, + ], + }, + }); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata())) + ); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ + bool: { + must_not: { + terms: { + 'elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + }, + }); + expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'] }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(10); + expect(endpointResultList.request_page_size).toEqual(10); + expect(endpointResultList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_1 + ); + }); + + it('test find the latest of all endpoints with paging and filters properties', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 1, + }, + ], + + filters: { kql: 'not host.ip:10.140.73.246' }, + }, + }); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata())) + ); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toBeCalled(); + expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ + bool: { + must: [ + { + bool: { + must_not: { + terms: { + 'elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }); + expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'] }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(10); + expect(endpointResultList.request_page_size).toEqual(10); + expect(endpointResultList.query_strategy_version).toEqual( + MetadataQueryStrategyVersions.VERSION_1 + ); + }); + + describe('Endpoint Details route', () => { + it('should return 404 on no results', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'BADID' } }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createV1SearchResponse()) + ); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.getAgent = jest.fn().mockReturnValue(({ + active: true, + } as unknown) as Agent); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) + )!; + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.notFound).toBeCalled(); + const message = mockResponse.notFound.mock.calls[0][0]?.body; + expect(message).toEqual('Endpoint Not Found'); + }); + + it('should return a single endpoint with status online', async () => { + const response = createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('online'); + mockAgentService.getAgent = jest.fn().mockReturnValue(({ + active: true, + } as unknown) as Agent); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; + expect(result).toHaveProperty('metadata.Endpoint'); + expect(result.host_status).toEqual(HostStatus.ONLINE); + }); + + it('should return a single endpoint with status error when AgentService throw 404', async () => { + const response = createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + + mockAgentService.getAgentStatusById = jest.fn().mockImplementation(() => { + SavedObjectsErrorHelpers.createGenericNotFoundError(); + }); + + mockAgentService.getAgent = jest.fn().mockImplementation(() => { + SavedObjectsErrorHelpers.createGenericNotFoundError(); + }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; + expect(result.host_status).toEqual(HostStatus.ERROR); + }); + + it('should return a single endpoint with status error when status is not offline, online or enrolling', async () => { + const response = createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('warning'); + mockAgentService.getAgent = jest.fn().mockReturnValue(({ + active: true, + } as unknown) as Agent); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; + expect(result.host_status).toEqual(HostStatus.ERROR); + }); + + it('should throw error when endpoint agent is not active', async () => { + const response = createV1SearchResponse(new EndpointDocGenerator().generateHostMetadata()); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + mockAgentService.getAgent = jest.fn().mockReturnValue(({ + active: false, + } as unknown) as Agent); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(`${METADATA_REQUEST_V1_ROUTE}`) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockResponse.customError).toBeCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts index 84da4a0960820..cb79263ef6b3c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts @@ -8,6 +8,7 @@ import { kibanaRequestToMetadataListESQuery, getESQueryHostMetadataByID } from ' import { EndpointAppContextService } from '../../endpoint_app_context_services'; import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { metadataCurrentIndexPattern } from '../../../../common/endpoint/constants'; +import { metadataQueryStrategyV2 } from './support/query_strategies'; describe('query builder', () => { describe('MetadataListESQuery', () => { @@ -22,7 +23,7 @@ describe('query builder', () => { service: new EndpointAppContextService(), config: () => Promise.resolve(createMockConfig()), }, - metadataCurrentIndexPattern + metadataQueryStrategyV2() ); expect(query).toEqual({ body: { @@ -59,7 +60,7 @@ describe('query builder', () => { service: new EndpointAppContextService(), config: () => Promise.resolve(createMockConfig()), }, - metadataCurrentIndexPattern, + metadataQueryStrategyV2(), { unenrolledAgentIds: [unenrolledElasticAgentId], } @@ -107,7 +108,7 @@ describe('query builder', () => { service: new EndpointAppContextService(), config: () => Promise.resolve(createMockConfig()), }, - metadataCurrentIndexPattern + metadataQueryStrategyV2() ); expect(query).toEqual({ @@ -166,7 +167,7 @@ describe('query builder', () => { service: new EndpointAppContextService(), config: () => Promise.resolve(createMockConfig()), }, - metadataCurrentIndexPattern, + metadataQueryStrategyV2(), { unenrolledAgentIds: [unenrolledElasticAgentId], } @@ -225,7 +226,7 @@ describe('query builder', () => { describe('MetadataGetQuery', () => { it('searches for the correct ID', () => { const mockID = 'AABBCCDD-0011-2233-AA44-DEADBEEF8899'; - const query = getESQueryHostMetadataByID(mockID, metadataCurrentIndexPattern); + const query = getESQueryHostMetadataByID(mockID, metadataQueryStrategyV2()); expect(query).toEqual({ body: { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts index 9002d328efbe3..0b166e097af94 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts @@ -5,7 +5,7 @@ */ import { KibanaRequest } from 'kibana/server'; import { esKuery } from '../../../../../../../src/plugins/data/server'; -import { EndpointAppContext } from '../../types'; +import { EndpointAppContext, MetadataQueryStrategy } from '../../types'; export interface QueryBuilderOptions { unenrolledAgentIds?: string[]; @@ -16,29 +16,26 @@ export async function kibanaRequestToMetadataListESQuery( // eslint-disable-next-line @typescript-eslint/no-explicit-any request: KibanaRequest, endpointAppContext: EndpointAppContext, - index: string, + metadataQueryStrategy: MetadataQueryStrategy, queryBuilderOptions?: QueryBuilderOptions // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise> { const pagingProperties = await getPagingProperties(request, endpointAppContext); + return { body: { query: buildQueryBody( request, + metadataQueryStrategy, queryBuilderOptions?.unenrolledAgentIds!, queryBuilderOptions?.statusAgentIDs! ), - sort: [ - { - 'HostDetails.event.created': { - order: 'desc', - }, - }, - ], + ...metadataQueryStrategy.extraBodyProperties, + sort: metadataQueryStrategy.sortProperty, }, from: pagingProperties.pageIndex * pagingProperties.pageSize, size: pagingProperties.pageSize, - index, + index: metadataQueryStrategy.index, }; } @@ -66,6 +63,7 @@ async function getPagingProperties( function buildQueryBody( // eslint-disable-next-line @typescript-eslint/no-explicit-any request: KibanaRequest, + metadataQueryStrategy: MetadataQueryStrategy, unerolledAgentIds: string[] | undefined, statusAgentIDs: string[] | undefined // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -75,7 +73,7 @@ function buildQueryBody( ? { must_not: { terms: { - 'HostDetails.elastic.agent.id': unerolledAgentIds, + [metadataQueryStrategy.elasticAgentIdProperty]: unerolledAgentIds, }, }, } @@ -84,7 +82,7 @@ function buildQueryBody( ? { must: { terms: { - 'HostDetails.elastic.agent.id': statusAgentIDs, + [metadataQueryStrategy.elasticAgentIdProperty]: statusAgentIDs, }, }, } @@ -117,23 +115,20 @@ function buildQueryBody( }; } -export function getESQueryHostMetadataByID(hostID: string, index: string) { +export function getESQueryHostMetadataByID( + hostID: string, + metadataQueryStrategy: MetadataQueryStrategy +) { return { body: { query: { match: { - 'HostDetails.host.id': hostID, + [metadataQueryStrategy.hostIdProperty]: hostID, }, }, - sort: [ - { - 'HostDetails.event.created': { - order: 'desc', - }, - }, - ], + sort: metadataQueryStrategy.sortProperty, size: 1, }, - index, + index: metadataQueryStrategy.index, }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders_v1.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders_v1.test.ts new file mode 100644 index 0000000000000..899fe4b880acd --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders_v1.test.ts @@ -0,0 +1,300 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { httpServerMock, loggingSystemMock } from '../../../../../../../src/core/server/mocks'; +import { kibanaRequestToMetadataListESQuery, getESQueryHostMetadataByID } from './query_builders'; +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; +import { metadataIndexPattern } from '../../../../common/endpoint/constants'; +import { metadataQueryStrategyV1 } from './support/query_strategies'; + +describe('query builder v1', () => { + describe('MetadataListESQuery', () => { + it('test default query params for all endpoints metadata when no params or body is provided', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + body: {}, + }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + logFactory: loggingSystemMock.create(), + service: new EndpointAppContextService(), + config: () => Promise.resolve(createMockConfig()), + }, + metadataQueryStrategyV1() + ); + expect(query).toEqual({ + body: { + query: { + match_all: {}, + }, + collapse: { + field: 'host.id', + inner_hits: { + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + aggs: { + total: { + cardinality: { + field: 'host.id', + }, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + }, + from: 0, + size: 10, + index: metadataIndexPattern, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Record); + }); + + it( + 'test default query params for all endpoints metadata when no params or body is provided ' + + 'with unenrolled host ids excluded', + async () => { + const unenrolledElasticAgentId = '1fdca33f-799f-49f4-939c-ea4383c77672'; + const mockRequest = httpServerMock.createKibanaRequest({ + body: {}, + }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + logFactory: loggingSystemMock.create(), + service: new EndpointAppContextService(), + config: () => Promise.resolve(createMockConfig()), + }, + metadataQueryStrategyV1(), + { + unenrolledAgentIds: [unenrolledElasticAgentId], + } + ); + expect(query).toEqual({ + body: { + query: { + bool: { + must_not: { + terms: { + 'elastic.agent.id': [unenrolledElasticAgentId], + }, + }, + }, + }, + collapse: { + field: 'host.id', + inner_hits: { + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + aggs: { + total: { + cardinality: { + field: 'host.id', + }, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + }, + from: 0, + size: 10, + index: metadataIndexPattern, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Record); + } + ); + }); + + describe('test query builder with kql filter', () => { + it('test default query params for all endpoints metadata when body filter is provided', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + filters: { kql: 'not host.ip:10.140.73.246' }, + }, + }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + logFactory: loggingSystemMock.create(), + service: new EndpointAppContextService(), + config: () => Promise.resolve(createMockConfig()), + }, + metadataQueryStrategyV1() + ); + + expect(query).toEqual({ + body: { + query: { + bool: { + must: [ + { + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }, + collapse: { + field: 'host.id', + inner_hits: { + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + aggs: { + total: { + cardinality: { + field: 'host.id', + }, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + }, + from: 0, + size: 10, + index: metadataIndexPattern, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Record); + }); + + it( + 'test default query params for all endpoints endpoint metadata excluding unerolled endpoint ' + + 'and when body filter is provided', + async () => { + const unenrolledElasticAgentId = '1fdca33f-799f-49f4-939c-ea4383c77672'; + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + filters: { kql: 'not host.ip:10.140.73.246' }, + }, + }); + const query = await kibanaRequestToMetadataListESQuery( + mockRequest, + { + logFactory: loggingSystemMock.create(), + service: new EndpointAppContextService(), + config: () => Promise.resolve(createMockConfig()), + }, + metadataQueryStrategyV1(), + { + unenrolledAgentIds: [unenrolledElasticAgentId], + } + ); + + expect(query).toEqual({ + body: { + query: { + bool: { + must: [ + { + bool: { + must_not: { + terms: { + 'elastic.agent.id': [unenrolledElasticAgentId], + }, + }, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }, + collapse: { + field: 'host.id', + inner_hits: { + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + aggs: { + total: { + cardinality: { + field: 'host.id', + }, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + }, + from: 0, + size: 10, + index: metadataIndexPattern, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as Record); + } + ); + }); + + describe('MetadataGetQuery', () => { + it('searches for the correct ID', () => { + const mockID = 'AABBCCDD-0011-2233-AA44-DEADBEEF8899'; + const query = getESQueryHostMetadataByID(mockID, metadataQueryStrategyV1()); + + expect(query).toEqual({ + body: { + query: { match: { 'host.id': mockID } }, + sort: [{ 'event.created': { order: 'desc' } }], + size: 1, + }, + index: metadataIndexPattern, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/route_schema_test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/route_schema_test.ts new file mode 100644 index 0000000000000..851c51618c79b --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/route_schema_test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { endpointFilters } from './index'; + +describe('Filters Schema Test', () => { + it('accepts a single host status', () => { + expect( + endpointFilters.validate({ + host_status: ['error'], + }) + ).toBeTruthy(); + }); + + it('accepts multiple host status filters', () => { + expect( + endpointFilters.validate({ + host_status: ['offline', 'unenrolling'], + }) + ).toBeTruthy(); + }); + + it('rejects invalid statuses', () => { + expect(() => + endpointFilters.validate({ + host_status: ['foobar'], + }) + ).toThrowError(); + }); + + it('accepts a KQL string', () => { + expect( + endpointFilters.validate({ + kql: 'whatever.field', + }) + ).toBeTruthy(); + }); + + it('accepts KQL + status', () => { + expect( + endpointFilters.validate({ + kql: 'thing.var', + host_status: ['online'], + }) + ).toBeTruthy(); + }); + + it('accepts no filters', () => { + expect(endpointFilters.validate({})).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/query_strategies.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/query_strategies.ts new file mode 100644 index 0000000000000..df4c377262466 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/query_strategies.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { SearchResponse } from 'elasticsearch'; +import { + metadataCurrentIndexPattern, + metadataIndexPattern, +} from '../../../../../common/endpoint/constants'; +import { + HostMetadata, + HostMetadataDetails, + MetadataQueryStrategyVersions, +} from '../../../../../common/endpoint/types'; +import { HostListQueryResult, HostQueryResult, MetadataQueryStrategy } from '../../../types'; + +interface HitSource { + _source: HostMetadata; +} + +export function metadataQueryStrategyV1(): MetadataQueryStrategy { + return { + index: metadataIndexPattern, + elasticAgentIdProperty: 'elastic.agent.id', + hostIdProperty: 'host.id', + sortProperty: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + extraBodyProperties: { + collapse: { + field: 'host.id', + inner_hits: { + name: 'most_recent', + size: 1, + sort: [{ 'event.created': 'desc' }], + }, + }, + aggs: { + total: { + cardinality: { + field: 'host.id', + }, + }, + }, + }, + queryResponseToHostListResult: ( + searchResponse: SearchResponse + ): HostListQueryResult => { + const response = searchResponse as SearchResponse; + return { + resultLength: response?.aggregations?.total?.value || 0, + resultList: response.hits.hits + .map((hit) => hit.inner_hits.most_recent.hits.hits) + .flatMap((data) => data as HitSource) + .map((entry) => entry._source), + queryStrategyVersion: MetadataQueryStrategyVersions.VERSION_1, + }; + }, + queryResponseToHostResult: ( + searchResponse: SearchResponse + ): HostQueryResult => { + const response = searchResponse as SearchResponse; + return { + resultLength: response.hits.hits.length, + result: response.hits.hits.length > 0 ? response.hits.hits[0]._source : undefined, + queryStrategyVersion: MetadataQueryStrategyVersions.VERSION_1, + }; + }, + }; +} + +export function metadataQueryStrategyV2(): MetadataQueryStrategy { + return { + index: metadataCurrentIndexPattern, + elasticAgentIdProperty: 'HostDetails.elastic.agent.id', + hostIdProperty: 'HostDetails.host.id', + sortProperty: [ + { + 'HostDetails.event.created': { + order: 'desc', + }, + }, + ], + queryResponseToHostListResult: ( + searchResponse: SearchResponse + ): HostListQueryResult => { + const response = searchResponse as SearchResponse; + return { + resultLength: + ((response.hits?.total as unknown) as { value: number; relation: string }).value || 0, + resultList: + response.hits.hits.length > 0 + ? response.hits.hits.map((entry) => entry._source.HostDetails) + : [], + queryStrategyVersion: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + queryResponseToHostResult: ( + searchResponse: SearchResponse + ): HostQueryResult => { + const response = searchResponse as SearchResponse; + return { + resultLength: response.hits.hits.length, + result: + response.hits.hits.length > 0 ? response.hits.hits[0]._source.HostDetails : undefined, + queryStrategyVersion: MetadataQueryStrategyVersions.VERSION_2, + }; + }, + }; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/test_support.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/test_support.ts new file mode 100644 index 0000000000000..ac6ee380c8b06 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/test_support.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SearchResponse } from 'elasticsearch'; +import { HostMetadata, HostMetadataDetails } from '../../../../../common/endpoint/types'; + +export function createV1SearchResponse(hostMetadata?: HostMetadata): SearchResponse { + return ({ + took: 15, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 5, + relation: 'eq', + }, + max_score: null, + hits: hostMetadata + ? [ + { + _index: 'metrics-endpoint.metadata-default', + _id: '8FhM0HEBYyRTvb6lOQnw', + _score: null, + _source: hostMetadata, + sort: [1588337587997], + inner_hits: { + most_recent: { + hits: { + total: { + value: 2, + relation: 'eq', + }, + max_score: null, + hits: [ + { + _index: 'metrics-endpoint.metadata-default', + _id: 'W6Vo1G8BYQH1gtPUgYkC', + _score: null, + _source: hostMetadata, + sort: [1579816615336], + }, + ], + }, + }, + }, + }, + ] + : [], + }, + aggregations: { + total: { + value: 1, + }, + }, + } as unknown) as SearchResponse; +} + +export function createV2SearchResponse( + hostMetadata?: HostMetadata +): SearchResponse { + return ({ + took: 15, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: null, + hits: hostMetadata + ? [ + { + _index: 'metrics-endpoint.metadata-default', + _id: '8FhM0HEBYyRTvb6lOQnw', + _score: null, + _source: { + agent: { + id: '1e3472bb-5c20-4946-b469-b5af1a809e4f', + }, + HostDetails: { + ...hostMetadata, + }, + }, + sort: [1588337587997], + }, + ] + : [], + }, + } as unknown) as SearchResponse; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/types.ts b/x-pack/plugins/security_solution/server/endpoint/types.ts index 3c6630db8ebd8..2328c86f78a35 100644 --- a/x-pack/plugins/security_solution/server/endpoint/types.ts +++ b/x-pack/plugins/security_solution/server/endpoint/types.ts @@ -4,8 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ import { LoggerFactory } from 'kibana/server'; +import { SearchResponse } from 'elasticsearch'; import { ConfigType } from '../config'; import { EndpointAppContextService } from './endpoint_app_context_services'; +import { JsonObject } from '../../../infra/common/typed_json'; +import { + HostMetadata, + HostMetadataDetails, + MetadataQueryStrategyVersions, +} from '../../common/endpoint/types'; /** * The context for Endpoint apps. @@ -19,3 +26,29 @@ export interface EndpointAppContext { */ service: EndpointAppContextService; } + +export interface HostListQueryResult { + resultLength: number; + resultList: HostMetadata[]; + queryStrategyVersion: MetadataQueryStrategyVersions; +} + +export interface HostQueryResult { + resultLength: number; + result: HostMetadata | undefined; + queryStrategyVersion: MetadataQueryStrategyVersions; +} + +export interface MetadataQueryStrategy { + index: string; + elasticAgentIdProperty: string; + hostIdProperty: string; + sortProperty: JsonObject[]; + extraBodyProperties?: JsonObject; + queryResponseToHostListResult: ( + searchResponse: SearchResponse + ) => HostListQueryResult; + queryResponseToHostResult: ( + searchResponse: SearchResponse + ) => HostQueryResult; +} diff --git a/x-pack/plugins/security_solution/server/lib/hosts/elasticsearch_adapter.test.ts b/x-pack/plugins/security_solution/server/lib/hosts/elasticsearch_adapter.test.ts index 766fbd5dca031..6abff93d6cd5c 100644 --- a/x-pack/plugins/security_solution/server/lib/hosts/elasticsearch_adapter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/hosts/elasticsearch_adapter.test.ts @@ -30,7 +30,12 @@ import { HostAggEsItem } from './types'; import { EndpointAppContext } from '../../endpoint/types'; import { mockLogger } from '../detection_engine/signals/__mocks__/es_results'; import { EndpointAppContextService } from '../../endpoint/endpoint_app_context_services'; -import { createMockEndpointAppContextServiceStartContract } from '../../endpoint/mocks'; +import { + createMockEndpointAppContextServiceStartContract, + createMockPackageService, +} from '../../endpoint/mocks'; +import { PackageService } from '../../../../ingest_manager/server/services'; +import { ElasticsearchAssetType } from '../../../../ingest_manager/common/types/models'; jest.mock('./query.hosts.dsl', () => { return { @@ -49,7 +54,7 @@ jest.mock('./query.last_first_seen_host.dsl', () => { buildLastFirstSeenHostQuery: jest.fn(() => mockGetHostLastFirstSeenDsl), }; }); -jest.mock('../../endpoint/routes/metadata', () => { +jest.mock('../../endpoint/routes/metadata/handlers', () => { return { getHostData: jest.fn(() => mockEndpointMetadata), }; @@ -167,8 +172,16 @@ describe('hosts elasticsearch_adapter', () => { const endpointAppContextService = new EndpointAppContextService(); const startContract = createMockEndpointAppContextServiceStartContract(); - endpointAppContextService.start(startContract); - + const mockPackageService: jest.Mocked = createMockPackageService(); + mockPackageService.getInstalledEsAssetReferences.mockReturnValue( + Promise.resolve([ + { + id: 'metrics-endpoint.metadata-current-default-0.16.0-dev.0', + type: ElasticsearchAssetType.transform, + }, + ]) + ); + endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); const endpointContext: EndpointAppContext = { logFactory: mockLogger, service: endpointAppContextService, diff --git a/x-pack/plugins/security_solution/server/lib/hosts/elasticsearch_adapter.ts b/x-pack/plugins/security_solution/server/lib/hosts/elasticsearch_adapter.ts index 142d2a68faed0..ff2796e6852d0 100644 --- a/x-pack/plugins/security_solution/server/lib/hosts/elasticsearch_adapter.ts +++ b/x-pack/plugins/security_solution/server/lib/hosts/elasticsearch_adapter.ts @@ -8,11 +8,11 @@ import { set } from '@elastic/safer-lodash-set/fp'; import { get, getOr, has, head } from 'lodash/fp'; import { + EndpointFields, FirstLastSeenHost, HostItem, HostsData, HostsEdges, - EndpointFields, } from '../../graphql/types'; import { inspectStringifyObject } from '../../utils/build_query'; import { hostFieldsMap } from '../ecs_fields'; @@ -25,16 +25,16 @@ import { HostAggEsData, HostAggEsItem, HostBuckets, - HostOverviewRequestOptions, HostEsData, HostLastFirstSeenRequestOptions, + HostOverviewRequestOptions, HostsAdapter, HostsRequestOptions, HostValue, } from './types'; import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../common/constants'; import { EndpointAppContext } from '../../endpoint/types'; -import { getHostData } from '../../endpoint/routes/metadata'; +import { getHostData } from '../../endpoint/routes/metadata/handlers'; export class ElasticsearchHostsAdapter implements HostsAdapter { constructor( @@ -116,12 +116,12 @@ export class ElasticsearchHostsAdapter implements HostsAdapter { throw new Error('agentService not available'); } const metadataRequestContext = { - agentService, + endpointAppContextService: this.endpointContext.service, logger, requestHandlerContext: request.context, }; const endpointData = - hostId != null && metadataRequestContext.agentService != null + hostId != null && metadataRequestContext.endpointAppContextService.getAgentService() != null ? await getHostData(metadataRequestContext, hostId) : null; return endpointData != null && endpointData.metadata diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index d203c6dcc48c4..f0e7372a208fb 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -315,6 +315,7 @@ export class Plugin implements IPlugin { ], ]; - describe('endpoint list', function () { + // Failing: See https://github.com/elastic/kibana/issues/77701 + describe.skip('endpoint list', function () { this.tags('ciGroup7'); const sleep = (ms = 100) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts index 8b4a73b7eb848..3d344c1b3b51b 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts @@ -28,6 +28,7 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider }); loadTestFile(require.resolve('./resolver/index')); loadTestFile(require.resolve('./metadata')); + loadTestFile(require.resolve('./metadata_v1')); loadTestFile(require.resolve('./policy')); loadTestFile(require.resolve('./artifacts')); loadTestFile(require.resolve('./package')); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 2286320ed7a88..b157c3159ccc0 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -11,6 +11,8 @@ import { deleteAllDocsFromMetadataIndex, deleteMetadataStream, } from './data_stream_helper'; +import { METADATA_REQUEST_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; +import { MetadataQueryStrategyVersions } from '../../../plugins/security_solution/common/endpoint/types'; /** * The number of host documents in the es archive. @@ -22,14 +24,14 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('test metadata api', () => { - describe('POST /api/endpoint/metadata when index is empty', () => { + describe(`POST ${METADATA_REQUEST_ROUTE} when index is empty`, () => { it('metadata api should return empty result when index is empty', async () => { await deleteMetadataStream(getService); await deleteAllDocsFromMetadataIndex(getService); await deleteMetadataCurrentStream(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -40,7 +42,7 @@ export default function ({ getService }: FtrProviderContext) { }); }); - describe('POST /api/endpoint/metadata when index is not empty', () => { + describe(`POST ${METADATA_REQUEST_ROUTE} when index is not empty`, () => { before(async () => { await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }); // wait for transform @@ -56,7 +58,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('metadata api should return one entry for each host with default paging', async () => { const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send() .expect(200); @@ -68,7 +70,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on paging properties passed.', async () => { const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -85,6 +87,7 @@ export default function ({ getService }: FtrProviderContext) { expect(body.hosts.length).to.eql(1); expect(body.request_page_size).to.eql(1); expect(body.request_page_index).to.eql(1); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_2); }); /* test that when paging properties produces no result, the total should reflect the actual number of metadata @@ -92,7 +95,7 @@ export default function ({ getService }: FtrProviderContext) { */ it('metadata api should return accurate total metadata if page index produces no result', async () => { const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -109,11 +112,12 @@ export default function ({ getService }: FtrProviderContext) { expect(body.hosts.length).to.eql(0); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(30); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_2); }); it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -131,7 +135,7 @@ export default function ({ getService }: FtrProviderContext) { it('metadata api should return page based on filters passed.', async () => { const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -143,12 +147,13 @@ export default function ({ getService }: FtrProviderContext) { expect(body.hosts.length).to.eql(2); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_2); }); it('metadata api should return page based on filters and paging passed.', async () => { const notIncludedIp = '10.46.229.234'; const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ paging_properties: [ @@ -180,12 +185,13 @@ export default function ({ getService }: FtrProviderContext) { expect(body.hosts.length).to.eql(2); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_2); }); it('metadata api should return page based on host.os.Ext.variant filter.', async () => { const variantValue = 'Windows Pro'; const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -201,12 +207,13 @@ export default function ({ getService }: FtrProviderContext) { expect(body.hosts.length).to.eql(2); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_2); }); it('metadata api should return the latest event for all the events for an endpoint', async () => { const targetEndpointIp = '10.46.229.234'; const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -223,11 +230,12 @@ export default function ({ getService }: FtrProviderContext) { expect(body.hosts.length).to.eql(1); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_2); }); it('metadata api should return the latest event for all the events where policy status is not success', async () => { const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -248,7 +256,7 @@ export default function ({ getService }: FtrProviderContext) { const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -266,11 +274,12 @@ export default function ({ getService }: FtrProviderContext) { expect(body.hosts.length).to.eql(1); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_2); }); it('metadata api should return all hosts when filter is empty string', async () => { const { body } = await supertest - .post('/api/endpoint/metadata') + .post(`${METADATA_REQUEST_ROUTE}`) .set('kbn-xsrf', 'xxx') .send({ filters: { @@ -282,6 +291,7 @@ export default function ({ getService }: FtrProviderContext) { expect(body.hosts.length).to.eql(numberOfHostsInFixture); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_2); }); }); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts new file mode 100644 index 0000000000000..548e5f6c768da --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata_v1.ts @@ -0,0 +1,284 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect/expect.js'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { deleteMetadataStream } from './data_stream_helper'; +import { METADATA_REQUEST_V1_ROUTE } from '../../../plugins/security_solution/server/endpoint/routes/metadata'; +import { MetadataQueryStrategyVersions } from '../../../plugins/security_solution/common/endpoint/types'; + +/** + * The number of host documents in the es archive. + */ +const numberOfHostsInFixture = 3; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + describe('test metadata api v1', () => { + describe(`POST ${METADATA_REQUEST_V1_ROUTE} when index is empty`, () => { + it('metadata api should return empty result when index is empty', async () => { + // the endpoint uses data streams and es archiver does not support deleting them at the moment so we need + // to do it manually + await deleteMetadataStream(getService); + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + expect(body.total).to.eql(0); + expect(body.hosts.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + }); + + describe(`POST ${METADATA_REQUEST_V1_ROUTE} when index is not empty`, () => { + before( + async () => await esArchiver.load('endpoint/metadata/api_feature', { useCreate: true }) + ); + // the endpoint uses data streams and es archiver does not support deleting them at the moment so we need + // to do it manually + after(async () => await deleteMetadataStream(getService)); + it('metadata api should return one entry for each host with default paging', async () => { + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.hosts.length).to.eql(numberOfHostsInFixture); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + + it('metadata api should return page based on paging properties passed.', async () => { + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 1, + }, + { + page_index: 1, + }, + ], + }) + .expect(200); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.hosts.length).to.eql(1); + expect(body.request_page_size).to.eql(1); + expect(body.request_page_index).to.eql(1); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + + /* test that when paging properties produces no result, the total should reflect the actual number of metadata + in the index. + */ + it('metadata api should return accurate total metadata if page index produces no result', async () => { + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 3, + }, + ], + }) + .expect(200); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.hosts.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(30); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + + it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 0, + }, + { + page_index: 1, + }, + ], + }) + .expect(400); + expect(body.message).to.contain('Value must be equal to or greater than [1]'); + }); + + it('metadata api should return page based on filters passed.', async () => { + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: 'not host.ip:10.46.229.234', + }, + }) + .expect(200); + expect(body.total).to.eql(2); + expect(body.hosts.length).to.eql(2); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + + it('metadata api should return page based on filters and paging passed.', async () => { + const notIncludedIp = '10.46.229.234'; + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 0, + }, + ], + filters: { + kql: `not host.ip:${notIncludedIp}`, + }, + }) + .expect(200); + expect(body.total).to.eql(2); + const resultIps: string[] = [].concat( + ...body.hosts.map((hostInfo: Record) => hostInfo.metadata.host.ip) + ); + expect(resultIps).to.eql([ + '10.192.213.130', + '10.70.28.129', + '10.101.149.26', + '2606:a000:ffc0:39:11ef:37b9:3371:578c', + ]); + expect(resultIps).not.include.eql(notIncludedIp); + expect(body.hosts.length).to.eql(2); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + + it('metadata api should return page based on host.os.Ext.variant filter.', async () => { + const variantValue = 'Windows Pro'; + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: `host.os.Ext.variant:${variantValue}`, + }, + }) + .expect(200); + expect(body.total).to.eql(2); + const resultOsVariantValue: Set = new Set( + body.hosts.map((hostInfo: Record) => hostInfo.metadata.host.os.Ext.variant) + ); + expect(Array.from(resultOsVariantValue)).to.eql([variantValue]); + expect(body.hosts.length).to.eql(2); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + + it('metadata api should return the latest event for all the events for an endpoint', async () => { + const targetEndpointIp = '10.46.229.234'; + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: `host.ip:${targetEndpointIp}`, + }, + }) + .expect(200); + expect(body.total).to.eql(1); + const resultIp: string = body.hosts[0].metadata.host.ip.filter( + (ip: string) => ip === targetEndpointIp + ); + expect(resultIp).to.eql([targetEndpointIp]); + expect(body.hosts[0].metadata.event.created).to.eql(1579881969541); + expect(body.hosts.length).to.eql(1); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + + it('metadata api should return the latest event for all the events where policy status is not success', async () => { + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: `not Endpoint.policy.applied.status:success`, + }, + }) + .expect(200); + const statuses: Set = new Set( + body.hosts.map( + (hostInfo: Record) => hostInfo.metadata.Endpoint.policy.applied.status + ) + ); + expect(statuses.size).to.eql(1); + expect(Array.from(statuses)).to.eql(['failure']); + }); + + it('metadata api should return the endpoint based on the elastic agent id, and status should be error', async () => { + const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; + const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: `elastic.agent.id:${targetElasticAgentId}`, + }, + }) + .expect(200); + expect(body.total).to.eql(1); + const resultHostId: string = body.hosts[0].metadata.host.id; + const resultElasticAgentId: string = body.hosts[0].metadata.elastic.agent.id; + expect(resultHostId).to.eql(targetEndpointId); + expect(resultElasticAgentId).to.eql(targetElasticAgentId); + expect(body.hosts[0].metadata.event.created).to.eql(1579881969541); + expect(body.hosts[0].host_status).to.eql('error'); + expect(body.hosts.length).to.eql(1); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + + it('metadata api should return all hosts when filter is empty string', async () => { + const { body } = await supertest + .post(`${METADATA_REQUEST_V1_ROUTE}`) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: '', + }, + }) + .expect(200); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.hosts.length).to.eql(numberOfHostsInFixture); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + expect(body.query_strategy_version).to.eql(MetadataQueryStrategyVersions.VERSION_1); + }); + }); + }); +} diff --git a/yarn.lock b/yarn.lock index 1916a336eb984..2aa0844ed46c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4262,12 +4262,13 @@ dependencies: "@types/node" "*" -"@types/node-fetch@^2.5.0": - version "2.5.0" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.0.tgz#1c55616a4591bdd15a389fbd0da4a55b9502add5" - integrity sha512-TLFRywthBgL68auWj+ziWu+vnmmcHCDFC/sqCOQf1xTz4hRq8cu79z8CtHU9lncExGBsB8fXA4TiLDLt6xvMzw== +"@types/node-fetch@^2.5.7": + version "2.5.7" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c" + integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw== dependencies: "@types/node" "*" + form-data "^3.0.0" "@types/node-forge@^0.9.5": version "0.9.5" @@ -9025,10 +9026,10 @@ colorspace@1.1.x: color "3.0.x" text-hex "1.0.x" -combined-stream@^1.0.6, combined-stream@~1.0.6: - version "1.0.7" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" - integrity sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w== +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== dependencies: delayed-stream "~1.0.0" @@ -11420,13 +11421,6 @@ encodeurl@^1.0.2, encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -encoding@^0.1.11: - version "0.1.12" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" - integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= - dependencies: - iconv-lite "~0.4.13" - end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1, end-of-stream@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -12479,11 +12473,6 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect.js@~0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/expect.js/-/expect.js-0.2.0.tgz#1028533d2c1c363f74a6796ff57ec0520ded2be1" - integrity sha1-EChTPSwcNj90pnlv9X7AUg3tK+E= - expect@^24.8.0, expect@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/expect/-/expect-24.9.0.tgz#b75165b4817074fa4a157794f46fe9f1ba15b6ca" @@ -13395,6 +13384,15 @@ form-data@^2.5.0: combined-stream "^1.0.6" mime-types "^2.1.12" +form-data@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" + integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + format@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" @@ -16801,7 +16799,7 @@ is-ssh@^1.3.0: dependencies: protocols "^1.1.0" -is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0: +is-stream@^1.0.0, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= @@ -20618,28 +20616,10 @@ node-environment-flags@1.0.6: object.getownpropertydescriptors "^2.0.3" semver "^5.7.0" -node-fetch@1.7.3, node-fetch@^1.0.1: - version "1.7.3" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" - integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== - dependencies: - encoding "^0.1.11" - is-stream "^1.0.1" - -node-fetch@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.1.2.tgz#ab884e8e7e57e38a944753cec706f788d1768bb5" - integrity sha1-q4hOjn5X44qUR1POxwb3iNF2i7U= - -node-fetch@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.3.0.tgz#1a1d940bbfb916a1d3e0219f037e89e71f8c5fa5" - integrity sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA== - -node-fetch@^2.6.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" - integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== +node-fetch@2.1.2, node-fetch@2.6.1, node-fetch@^1.0.1, node-fetch@^2.3.0, node-fetch@^2.6.0, node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== node-forge@0.9.0: version "0.9.0" @@ -24262,16 +24242,6 @@ readable-stream@1.0, "readable-stream@>=1.0.33-1 <1.1.0-0": 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" - integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk= - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - readable-stream@~2.0.0: version "2.0.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" @@ -25966,16 +25936,13 @@ shallowequal@1.1.0, shallowequal@^1.1.0: integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== sharkdown@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/sharkdown/-/sharkdown-0.1.0.tgz#61d4fe529e75d02442127cc9234362265099214f" - integrity sha1-YdT+Up510CRCEnzJI0NiJlCZIU8= + version "0.1.1" + resolved "https://registry.yarnpkg.com/sharkdown/-/sharkdown-0.1.1.tgz#64484bd0f08f347f8319e9ff947a670f6b48b1b2" + integrity sha512-exwooSpmo5s45lrexgz6Q0rFQM574wYIX3iDZ7RLLqOb7IAoQZu9nxlZODU972g19sR69OIpKP2cpHTzU+PHIg== dependencies: cardinal "~0.4.2" - expect.js "~0.2.0" minimist "0.0.5" split "~0.2.10" - stream-spigot "~2.1.2" - through "~2.3.4" shebang-command@^1.2.0: version "1.2.0" @@ -26751,13 +26718,6 @@ stream-slicer@0.0.6: resolved "https://registry.yarnpkg.com/stream-slicer/-/stream-slicer-0.0.6.tgz#f86b2ac5c2440b7a0a87b71f33665c0788046138" integrity sha1-+GsqxcJEC3oKh7cfM2ZcB4gEYTg= -stream-spigot@~2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/stream-spigot/-/stream-spigot-2.1.2.tgz#7de145e819f8dd0db45090d13dcf73a8ed3cc035" - integrity sha1-feFF6Bn43Q20UJDRPc9zqO08wDU= - dependencies: - readable-stream "~1.1.0" - strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"