diff --git a/.ci/Dockerfile b/.ci/Dockerfile index 065b7f2aafd61..d6eee046611bb 100644 --- a/.ci/Dockerfile +++ b/.ci/Dockerfile @@ -1,7 +1,7 @@ # NOTE: This Dockerfile is ONLY used to run certain tasks in CI. It is not used to run Kibana or as a distributable. # If you're looking for the Kibana Docker image distributable, please see: src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts -ARG NODE_VERSION=10.22.0 +ARG NODE_VERSION=10.22.1 FROM node:${NODE_VERSION} AS base diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 7727cd322181f..501a3698d07d9 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -494,6 +494,10 @@ in their infrastructure. |This plugins adopts some conventions in addition to or in place of conventions in Kibana (at the time of the plugin's creation): +|{kib-repo}blob/{branch}/x-pack/plugins/xpack_legacy/README.md[xpackLegacy] +|Contains HTTP endpoints and UiSettings that are slated for removal. + + |=== include::{kibana-root}/src/plugins/dashboard/README.asciidoc[leveloffset=+1] diff --git a/package.json b/package.json index 1395050263fd6..c88fbc0e5fd07 100644 --- a/package.json +++ b/package.json @@ -78,27 +78,18 @@ }, "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", - "**/cypress/@types/lodash": "^4.14.159", - "**/cypress/lodash": "^4.17.20", - "**/typescript": "4.0.2", + "**/cross-fetch/node-fetch": "^2.6.1", + "**/deepmerge": "^4.2.2", + "**/fast-deep-equal": "^3.1.1", "**/graphql-toolkit/lodash": "^4.17.15", "**/hoist-non-react-statics": "^3.3.2", - "**/isomorphic-git/**/base64-js": "^1.2.1", + "**/isomorphic-fetch/node-fetch": "^2.6.1", "**/istanbul-instrumenter-loader/schema-utils": "1.0.0", - "**/image-diff/gm/debug": "^2.6.9", "**/load-grunt-config/lodash": "^4.17.20", + "**/minimist": "^1.2.5", "**/node-jose/node-forge": "^0.10.0", - "**/react-dom": "^16.12.0", - "**/react": "^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 +185,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/context/context_service.mock.ts b/src/core/server/context/context_service.mock.ts index a8d895acad624..24e0d52100bb1 100644 --- a/src/core/server/context/context_service.mock.ts +++ b/src/core/server/context/context_service.mock.ts @@ -21,9 +21,9 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { ContextService, ContextSetup } from './context_service'; import { contextMock } from '../../utils/context.mock'; -const createSetupContractMock = () => { +const createSetupContractMock = (mockContext = {}) => { const setupContract: jest.Mocked = { - createContextContainer: jest.fn().mockImplementation(() => contextMock.create()), + createContextContainer: jest.fn().mockImplementation(() => contextMock.create(mockContext)), }; return setupContract; }; 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/core/server/mocks.ts b/src/core/server/mocks.ts index 52dccb6880882..2f995ff915293 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -52,6 +52,7 @@ export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_object export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { metricsServiceMock } from './metrics/metrics_service.mock'; export { renderingMock } from './rendering/rendering_service.mock'; +export { contextServiceMock } from './context/context_service.mock'; export function pluginInitializerContextConfigMock(config: T) { const globalConfig: SharedGlobalConfig = { diff --git a/src/core/utils/context.mock.ts b/src/core/utils/context.mock.ts index de844f3f0f07d..273d64ec8f822 100644 --- a/src/core/utils/context.mock.ts +++ b/src/core/utils/context.mock.ts @@ -21,15 +21,13 @@ import { IContextContainer } from './context'; export type ContextContainerMock = jest.Mocked>; -const createContextMock = () => { +const createContextMock = (mockContext = {}) => { const contextMock: ContextContainerMock = { registerContext: jest.fn(), - createHandler: jest.fn((id, handler) => (...args: any[]) => - Promise.resolve(handler({}, ...args)) - ), + createHandler: jest.fn(), }; contextMock.createHandler.mockImplementation((pluginId, handler) => (...args) => - handler({}, ...args) + handler(mockContext, ...args) ); return contextMock; }; 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/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md index 1ffd01fc6fde7..cb80538fd1718 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md @@ -28,10 +28,10 @@ This collection occurs by default for every application registered via the menti ## Developer notes -In order to keep the count of the events, this collector uses 2 Saved Objects: +In order to keep the count of the events, this collector uses 3 Saved Objects: -1. `application_usage_transactional`: It stores each individually reported event (up to 90 days old). Grouped by `timestamp` and `appId`. -2. `application_usage_totals`: It stores the sum of all the events older than 90 days old per `appId`. +1. `application_usage_transactional`: It stores each individually reported event. Grouped by `timestamp` and `appId`. The reason for having these documents instead of editing `application_usage_daily` documents on very report is to provide faster response to the requests to `/api/ui_metric/report` (creating new documents instead of finding and editing existing ones) and to avoid conflicts when multiple users reach to the API concurrently. +2. `application_usage_daily`: Periodically, documents from `application_usage_transactional` are aggregated to daily summaries and deleted. Also grouped by `timestamp` and `appId`. +3. `application_usage_totals`: It stores the sum of all the events older than 90 days old, grouped by `appId`. -Both of them use the shared fields `appId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`. `application_usage_transactional` also stores `timestamp: { type: 'date' }`. -but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). +All the types use the shared fields `appId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`, but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). `application_usage_transactional` and `application_usage_daily` also store `timestamp: { type: 'date' }`. diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts deleted file mode 100644 index 5658b38120596..0000000000000 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts +++ /dev/null @@ -1,151 +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 { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; -import { - CollectorOptions, - createUsageCollectionSetupMock, -} from '../../../../usage_collection/server/usage_collection.mock'; - -import { registerApplicationUsageCollector } from './'; -import { - ROLL_INDICES_INTERVAL, - SAVED_OBJECTS_TOTAL_TYPE, - SAVED_OBJECTS_TRANSACTIONAL_TYPE, -} from './telemetry_application_usage_collector'; - -describe('telemetry_application_usage', () => { - jest.useFakeTimers(); - - let collector: CollectorOptions; - - const usageCollectionMock = createUsageCollectionSetupMock(); - usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = config; - return createUsageCollectionSetupMock().makeUsageCollector(config); - }); - - const getUsageCollector = jest.fn(); - const registerType = jest.fn(); - const callCluster = jest.fn(); - - beforeAll(() => - registerApplicationUsageCollector(usageCollectionMock, registerType, getUsageCollector) - ); - afterAll(() => jest.clearAllTimers()); - - test('registered collector is set', () => { - expect(collector).not.toBeUndefined(); - }); - - test('if no savedObjectClient initialised, return undefined', async () => { - expect(await collector.fetch(callCluster)).toBeUndefined(); - jest.runTimersToTime(ROLL_INDICES_INTERVAL); - }); - - test('when savedObjectClient is initialised, return something', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) - ); - getUsageCollector.mockImplementation(() => savedObjectClient); - - jest.runTimersToTime(ROLL_INDICES_INTERVAL); // Force rollTotals to run - - expect(await collector.fetch(callCluster)).toStrictEqual({}); - expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); - }); - - test('paging in findAll works', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - let total = 201; - savedObjectClient.find.mockImplementation(async (opts) => { - if (opts.type === SAVED_OBJECTS_TOTAL_TYPE) { - return { - saved_objects: [ - { - id: 'appId', - attributes: { - appId: 'appId', - minutesOnScreen: 10, - numberOfClicks: 10, - }, - }, - ], - total: 1, - } as any; - } - if ((opts.page || 1) > 2) { - return { saved_objects: [], total }; - } - const doc = { - id: 'test-id', - attributes: { - appId: 'appId', - timestamp: new Date().toISOString(), - minutesOnScreen: 1, - numberOfClicks: 1, - }, - }; - const savedObjects = new Array(opts.perPage).fill(doc); - total = savedObjects.length * 2 + 1; - return { saved_objects: savedObjects, total }; - }); - - getUsageCollector.mockImplementation(() => savedObjectClient); - - jest.runTimersToTime(ROLL_INDICES_INTERVAL); // Force rollTotals to run - - expect(await collector.fetch(callCluster)).toStrictEqual({ - appId: { - clicks_total: total - 1 + 10, - clicks_7_days: total - 1, - clicks_30_days: total - 1, - clicks_90_days: total - 1, - minutes_on_screen_total: total - 1 + 10, - minutes_on_screen_7_days: total - 1, - minutes_on_screen_30_days: total - 1, - minutes_on_screen_90_days: total - 1, - }, - }); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( - [ - { - id: 'appId', - type: SAVED_OBJECTS_TOTAL_TYPE, - attributes: { - appId: 'appId', - minutesOnScreen: total - 1 + 10, - numberOfClicks: total - 1 + 10, - }, - }, - ], - { overwrite: true } - ); - expect(savedObjectClient.delete).toHaveBeenCalledTimes(total - 1); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_TRANSACTIONAL_TYPE, - 'test-id' - ); - }); -}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts new file mode 100644 index 0000000000000..f8bc17fc40df0 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts @@ -0,0 +1,299 @@ +/* + * 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 { rollDailyData, rollTotals } from './rollups'; +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks'; +import { SavedObjectsErrorHelpers } from '../../../../../core/server'; +import { + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, +} from './saved_objects_types'; + +describe('rollDailyData', () => { + const logger = loggingSystemMock.createLogger(); + + test('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollDailyData(logger, undefined)).resolves.toBe(undefined); + }); + + test('handle empty results', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.get).not.toBeCalled(); + expect(savedObjectClient.bulkCreate).not.toBeCalled(); + expect(savedObjectClient.delete).not.toBeCalled(); + }); + + test('migrate some docs', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + let timesCalled = 0; + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + if (timesCalled++ > 0) { + return { saved_objects: [], total: 0, page, per_page: perPage }; + } + return { + saved_objects: [ + { + id: 'test-id-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'test-id-2', + type, + score: 0, + references: [], + attributes: { + appId: 'appId', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 1.5, + numberOfClicks: 2, + }, + }, + ], + total: 2, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + + savedObjectClient.get.mockImplementation(async (type, id) => { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + }); + + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectClient.get).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId:2020-01-01' + ); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + type: SAVED_OBJECTS_DAILY_TYPE, + id: 'appId:2020-01-01', + attributes: { + appId: 'appId', + timestamp: '2020-01-01T00:00:00.000Z', + minutesOnScreen: 2.0, + numberOfClicks: 3, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_TRANSACTIONAL_TYPE, + 'test-id-1' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_TRANSACTIONAL_TYPE, + 'test-id-2' + ); + }); + + test('error getting the daily document', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + let timesCalled = 0; + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + if (timesCalled++ > 0) { + return { saved_objects: [], total: 0, page, per_page: perPage }; + } + return { + saved_objects: [ + { + id: 'test-id-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + + savedObjectClient.get.mockImplementation(async (type, id) => { + throw new Error('Something went terribly wrong'); + }); + + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectClient.get).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId:2020-01-01' + ); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); + }); +}); + +describe('rollTotals', () => { + const logger = loggingSystemMock.createLogger(); + + test('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollTotals(logger, undefined)).resolves.toBe(undefined); + }); + + test('handle empty results', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + case SAVED_OBJECTS_TOTAL_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); + }); + + test('migrate some documents', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + return { + saved_objects: [ + { + id: 'appId-2:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-2', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'appId-1:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 1.5, + numberOfClicks: 2, + }, + }, + ], + total: 2, + page, + per_page: perPage, + }; + case SAVED_OBJECTS_TOTAL_TYPE: + return { + saved_objects: [ + { + id: 'appId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-1', + attributes: { + appId: 'appId-1', + minutesOnScreen: 2.0, + numberOfClicks: 3, + }, + }, + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-2', + attributes: { + appId: 'appId-2', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-2:2020-01-01' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-1:2020-01-01' + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts new file mode 100644 index 0000000000000..3020147e95d98 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts @@ -0,0 +1,202 @@ +/* + * 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 { ISavedObjectsRepository, SavedObject, Logger } from 'kibana/server'; +import moment from 'moment'; +import { + ApplicationUsageDaily, + ApplicationUsageTotal, + ApplicationUsageTransactional, + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, +} from './saved_objects_types'; +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +/** + * For Rolling the daily data, we only care about the stored attributes and the version (to avoid overwriting via concurrent requests) + */ +type ApplicationUsageDailyWithVersion = Pick< + SavedObject, + 'version' | 'attributes' +>; + +/** + * Aggregates all the transactional events into daily aggregates + * @param logger + * @param savedObjectsClient + */ +export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { + if (!savedObjectsClient) { + return; + } + + try { + let toCreate: Map; + do { + toCreate = new Map(); + const { saved_objects: rawApplicationUsageTransactional } = await savedObjectsClient.find< + ApplicationUsageTransactional + >({ + type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + perPage: 1000, // Process 1000 at a time as a compromise of speed and overload + }); + + for (const doc of rawApplicationUsageTransactional) { + const { + attributes: { appId, minutesOnScreen, numberOfClicks, timestamp }, + } = doc; + const dayId = moment(timestamp).format('YYYY-MM-DD'); + const dailyId = `${appId}:${dayId}`; + const existingDoc = + toCreate.get(dailyId) || (await getDailyDoc(savedObjectsClient, dailyId, appId, dayId)); + toCreate.set(dailyId, { + ...existingDoc, + attributes: { + ...existingDoc.attributes, + minutesOnScreen: existingDoc.attributes.minutesOnScreen + minutesOnScreen, + numberOfClicks: existingDoc.attributes.numberOfClicks + numberOfClicks, + }, + }); + } + if (toCreate.size > 0) { + await savedObjectsClient.bulkCreate( + [...toCreate.entries()].map(([id, { attributes, version }]) => ({ + type: SAVED_OBJECTS_DAILY_TYPE, + id, + attributes, + version, // Providing version to ensure via conflict matching that only 1 Kibana instance (or interval) is taking care of the updates + })), + { overwrite: true } + ); + await Promise.all( + rawApplicationUsageTransactional.map( + ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_TRANSACTIONAL_TYPE, id) // There is no bulkDelete :( + ) + ); + } + } while (toCreate.size > 0); + } catch (err) { + logger.warn(`Failed to rollup transactional to daily entries`); + logger.warn(err); + } +} + +/** + * Gets daily doc from the SavedObjects repository. Creates a new one if not found + * @param savedObjectsClient + * @param id The ID of the document to retrieve (typically, `${appId}:${dayId}`) + * @param appId The application ID + * @param dayId The date of the document in the format YYYY-MM-DD + */ +async function getDailyDoc( + savedObjectsClient: ISavedObjectsRepository, + id: string, + appId: string, + dayId: string +): Promise { + try { + return await savedObjectsClient.get(SAVED_OBJECTS_DAILY_TYPE, id); + } catch (err) { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + return { + attributes: { + appId, + // Concatenating the day in YYYY-MM-DD form to T00:00:00Z to reduce the TZ effects + timestamp: moment(`${moment(dayId).format('YYYY-MM-DD')}T00:00:00Z`).toISOString(), + minutesOnScreen: 0, + numberOfClicks: 0, + }, + }; + } + throw err; + } +} + +/** + * Moves all the daily documents into aggregated "total" documents as we don't care about any granularity after 90 days + * @param logger + * @param savedObjectsClient + */ +export async function rollTotals(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { + if (!savedObjectsClient) { + return; + } + + try { + const [ + { saved_objects: rawApplicationUsageTotals }, + { saved_objects: rawApplicationUsageDaily }, + ] = await Promise.all([ + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_TOTAL_TYPE, + }), + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_DAILY_TYPE, + filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.timestamp < now-90d`, + }), + ]); + + const existingTotals = rawApplicationUsageTotals.reduce( + (acc, { attributes: { appId, numberOfClicks, minutesOnScreen } }) => { + return { + ...acc, + // No need to sum because there should be 1 document per appId only + [appId]: { appId, numberOfClicks, minutesOnScreen }, + }; + }, + {} as Record + ); + + const totals = rawApplicationUsageDaily.reduce((acc, { attributes }) => { + const { appId, numberOfClicks, minutesOnScreen } = attributes; + + const existing = acc[appId] || { minutesOnScreen: 0, numberOfClicks: 0 }; + + return { + ...acc, + [appId]: { + appId, + numberOfClicks: numberOfClicks + existing.numberOfClicks, + minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, + }, + }; + }, existingTotals); + + await Promise.all([ + Object.entries(totals).length && + savedObjectsClient.bulkCreate( + Object.entries(totals).map(([id, entry]) => ({ + type: SAVED_OBJECTS_TOTAL_TYPE, + id, + attributes: entry, + })), + { overwrite: true } + ), + ...rawApplicationUsageDaily.map( + ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_DAILY_TYPE, id) // There is no bulkDelete :( + ), + ]); + } catch (err) { + logger.warn(`Failed to rollup daily entries to totals`); + logger.warn(err); + } +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts index 551c6e230972e..861dc98c0c465 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts @@ -19,19 +19,34 @@ import { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server'; +/** + * Used for accumulating the totals of all the stats older than 90d + */ export interface ApplicationUsageTotal extends SavedObjectAttributes { appId: string; minutesOnScreen: number; numberOfClicks: number; } +export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; +/** + * Used for storing each of the reports received from the users' browsers + */ export interface ApplicationUsageTransactional extends ApplicationUsageTotal { timestamp: string; } +export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; + +/** + * Used to aggregate the transactional events into daily summaries so we can purge the granular events + */ +export type ApplicationUsageDaily = ApplicationUsageTransactional; +export const SAVED_OBJECTS_DAILY_TYPE = 'application_usage_daily'; export function registerMappings(registerType: SavedObjectsServiceSetup['registerType']) { + // Type for storing ApplicationUsageTotal registerType({ - name: 'application_usage_totals', + name: SAVED_OBJECTS_TOTAL_TYPE, hidden: false, namespaceType: 'agnostic', mappings: { @@ -42,15 +57,28 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe }, }); + // Type for storing ApplicationUsageDaily registerType({ - name: 'application_usage_transactional', + name: SAVED_OBJECTS_DAILY_TYPE, hidden: false, namespaceType: 'agnostic', mappings: { dynamic: false, properties: { + // This type requires `timestamp` to be indexed so we can use it when rolling up totals (timestamp < now-90d) timestamp: { type: 'date' }, }, }, }); + + // Type for storing ApplicationUsageTransactional (declaring empty mappings because we don't use the internal fields for query/aggregations) + registerType({ + name: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, + }); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts new file mode 100644 index 0000000000000..709736a37d802 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -0,0 +1,213 @@ +/* + * 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 { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks'; +import { + CollectorOptions, + createUsageCollectionSetupMock, +} from '../../../../usage_collection/server/usage_collection.mock'; + +import { + ROLL_INDICES_START, + ROLL_TOTAL_INDICES_INTERVAL, + registerApplicationUsageCollector, +} from './telemetry_application_usage_collector'; +import { + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, +} from './saved_objects_types'; + +describe('telemetry_application_usage', () => { + jest.useFakeTimers(); + + const logger = loggingSystemMock.createLogger(); + + let collector: CollectorOptions; + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = config; + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const getUsageCollector = jest.fn(); + const registerType = jest.fn(); + const callCluster = jest.fn(); + + beforeAll(() => + registerApplicationUsageCollector(logger, usageCollectionMock, registerType, getUsageCollector) + ); + afterAll(() => jest.clearAllTimers()); + + test('registered collector is set', () => { + expect(collector).not.toBeUndefined(); + }); + + test('if no savedObjectClient initialised, return undefined', async () => { + expect(collector.isReady()).toBe(false); + expect(await collector.fetch(callCluster)).toBeUndefined(); + jest.runTimersToTime(ROLL_INDICES_START); + }); + + test('when savedObjectClient is initialised, return something', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation( + async () => + ({ + saved_objects: [], + total: 0, + } as any) + ); + getUsageCollector.mockImplementation(() => savedObjectClient); + + jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run + + expect(collector.isReady()).toBe(true); + expect(await collector.fetch(callCluster)).toStrictEqual({}); + expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); + }); + + test('it only gets 10k even when there are more documents (ES limitation)', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + const total = 10000; + savedObjectClient.find.mockImplementation(async (opts) => { + switch (opts.type) { + case SAVED_OBJECTS_TOTAL_TYPE: + return { + saved_objects: [ + { + id: 'appId', + attributes: { + appId: 'appId', + minutesOnScreen: 10, + numberOfClicks: 10, + }, + }, + ], + total: 1, + } as any; + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + const doc = { + id: 'test-id', + attributes: { + appId: 'appId', + timestamp: new Date().toISOString(), + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }; + const savedObjects = new Array(total).fill(doc); + return { saved_objects: savedObjects, total: total + 1 }; + case SAVED_OBJECTS_DAILY_TYPE: + return { + saved_objects: [ + { + id: 'appId:YYYY-MM-DD', + attributes: { + appId: 'appId', + timestamp: new Date().toISOString(), + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + }; + } + }); + + getUsageCollector.mockImplementation(() => savedObjectClient); + + jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run + + expect(await collector.fetch(callCluster)).toStrictEqual({ + appId: { + clicks_total: total + 1 + 10, + clicks_7_days: total + 1, + clicks_30_days: total + 1, + clicks_90_days: total + 1, + minutes_on_screen_total: (total + 1) * 0.5 + 10, + minutes_on_screen_7_days: (total + 1) * 0.5, + minutes_on_screen_30_days: (total + 1) * 0.5, + minutes_on_screen_90_days: (total + 1) * 0.5, + }, + }); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + id: 'appId', + type: SAVED_OBJECTS_TOTAL_TYPE, + attributes: { + appId: 'appId', + minutesOnScreen: 10.5, + numberOfClicks: 11, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(1); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId:YYYY-MM-DD' + ); + }); + + test('old transactional data not migrated yet', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async (opts) => { + switch (opts.type) { + case SAVED_OBJECTS_TOTAL_TYPE: + case SAVED_OBJECTS_DAILY_TYPE: + return { saved_objects: [], total: 0 } as any; + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + return { + saved_objects: [ + { + id: 'test-id', + attributes: { + appId: 'appId', + timestamp: new Date(0).toISOString(), + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + }; + } + }); + + getUsageCollector.mockImplementation(() => savedObjectClient); + + expect(await collector.fetch(callCluster)).toStrictEqual({ + appId: { + clicks_total: 1, + clicks_7_days: 0, + clicks_30_days: 0, + clicks_90_days: 0, + minutes_on_screen_total: 0.5, + minutes_on_screen_7_days: 0, + minutes_on_screen_30_days: 0, + minutes_on_screen_90_days: 0, + }, + }); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts index 69137681e0597..36c89d0a0b4a8 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts @@ -18,29 +18,42 @@ */ import moment from 'moment'; -import { ISavedObjectsRepository, SavedObjectsServiceSetup } from 'kibana/server'; +import { timer } from 'rxjs'; +import { ISavedObjectsRepository, Logger, SavedObjectsServiceSetup } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { findAll } from '../find_all'; import { + ApplicationUsageDaily, ApplicationUsageTotal, ApplicationUsageTransactional, registerMappings, + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, } from './saved_objects_types'; import { applicationUsageSchema } from './schema'; +import { rollDailyData, rollTotals } from './rollups'; /** - * Roll indices every 24h + * Roll total indices every 24h */ -export const ROLL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; +export const ROLL_TOTAL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; + +/** + * Roll daily indices every 30 minutes. + * This means that, assuming a user can visit all the 44 apps we can possibly report + * in the 3 minutes interval the browser reports to the server, up to 22 users can have the same + * behaviour and we wouldn't need to paginate in the transactional documents (less than 10k docs). + * + * Based on a more normal expected use case, the users could visit up to 5 apps in those 3 minutes, + * allowing up to 200 users before reaching the limit. + */ +export const ROLL_DAILY_INDICES_INTERVAL = 30 * 60 * 1000; /** * Start rolling indices after 5 minutes up */ export const ROLL_INDICES_START = 5 * 60 * 1000; -export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; -export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; - export interface ApplicationUsageTelemetryReport { [appId: string]: { clicks_total: number; @@ -55,6 +68,7 @@ export interface ApplicationUsageTelemetryReport { } export function registerApplicationUsageCollector( + logger: Logger, usageCollection: UsageCollectionSetup, registerType: SavedObjectsServiceSetup['registerType'], getSavedObjectsClient: () => ISavedObjectsRepository | undefined @@ -71,10 +85,22 @@ export function registerApplicationUsageCollector( if (typeof savedObjectsClient === 'undefined') { return; } - const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([ - findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }), - findAll(savedObjectsClient, { + const [ + { saved_objects: rawApplicationUsageTotals }, + { saved_objects: rawApplicationUsageDaily }, + { saved_objects: rawApplicationUsageTransactional }, + ] = await Promise.all([ + savedObjectsClient.find({ + type: SAVED_OBJECTS_TOTAL_TYPE, + perPage: 10000, // We only have 44 apps for now. This limit is OK. + }), + savedObjectsClient.find({ + type: SAVED_OBJECTS_DAILY_TYPE, + perPage: 10000, // We can have up to 44 apps * 91 days = 4004 docs. This limit is OK + }), + savedObjectsClient.find({ type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + perPage: 10000, // If we have more than those, we won't report the rest (they'll be rolled up to the daily soon enough to become a problem) }), ]); @@ -101,51 +127,51 @@ export function registerApplicationUsageCollector( const nowMinus30 = moment().subtract(30, 'days'); const nowMinus90 = moment().subtract(90, 'days'); - const applicationUsage = rawApplicationUsageTransactional.reduce( - (acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => { - const existing = acc[appId] || { - clicks_total: 0, - clicks_7_days: 0, - clicks_30_days: 0, - clicks_90_days: 0, - minutes_on_screen_total: 0, - minutes_on_screen_7_days: 0, - minutes_on_screen_30_days: 0, - minutes_on_screen_90_days: 0, - }; - - const timeOfEntry = moment(timestamp as string); - const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7); - const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30); - const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90); - - const last7Days = { - clicks_7_days: existing.clicks_7_days + numberOfClicks, - minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen, - }; - const last30Days = { - clicks_30_days: existing.clicks_30_days + numberOfClicks, - minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen, - }; - const last90Days = { - clicks_90_days: existing.clicks_90_days + numberOfClicks, - minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen, - }; - - return { - ...acc, - [appId]: { - ...existing, - clicks_total: existing.clicks_total + numberOfClicks, - minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen, - ...(isInLast7Days ? last7Days : {}), - ...(isInLast30Days ? last30Days : {}), - ...(isInLast90Days ? last90Days : {}), - }, - }; - }, - applicationUsageFromTotals - ); + const applicationUsage = [ + ...rawApplicationUsageDaily, + ...rawApplicationUsageTransactional, + ].reduce((acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => { + const existing = acc[appId] || { + clicks_total: 0, + clicks_7_days: 0, + clicks_30_days: 0, + clicks_90_days: 0, + minutes_on_screen_total: 0, + minutes_on_screen_7_days: 0, + minutes_on_screen_30_days: 0, + minutes_on_screen_90_days: 0, + }; + + const timeOfEntry = moment(timestamp); + const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7); + const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30); + const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90); + + const last7Days = { + clicks_7_days: existing.clicks_7_days + numberOfClicks, + minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen, + }; + const last30Days = { + clicks_30_days: existing.clicks_30_days + numberOfClicks, + minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen, + }; + const last90Days = { + clicks_90_days: existing.clicks_90_days + numberOfClicks, + minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen, + }; + + return { + ...acc, + [appId]: { + ...existing, + clicks_total: existing.clicks_total + numberOfClicks, + minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen, + ...(isInLast7Days ? last7Days : {}), + ...(isInLast30Days ? last30Days : {}), + ...(isInLast90Days ? last90Days : {}), + }, + }; + }, applicationUsageFromTotals); return applicationUsage; }, @@ -154,65 +180,10 @@ export function registerApplicationUsageCollector( usageCollection.registerCollector(collector); - setInterval(() => rollTotals(getSavedObjectsClient()), ROLL_INDICES_INTERVAL); - setTimeout(() => rollTotals(getSavedObjectsClient()), ROLL_INDICES_START); -} - -async function rollTotals(savedObjectsClient?: ISavedObjectsRepository) { - if (!savedObjectsClient) { - return; - } - - try { - const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([ - findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }), - findAll(savedObjectsClient, { - type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, - filter: `${SAVED_OBJECTS_TRANSACTIONAL_TYPE}.attributes.timestamp < now-90d`, - }), - ]); - - const existingTotals = rawApplicationUsageTotals.reduce( - (acc, { attributes: { appId, numberOfClicks, minutesOnScreen } }) => { - return { - ...acc, - // No need to sum because there should be 1 document per appId only - [appId]: { appId, numberOfClicks, minutesOnScreen }, - }; - }, - {} as Record - ); - - const totals = rawApplicationUsageTransactional.reduce((acc, { attributes, id }) => { - const { appId, numberOfClicks, minutesOnScreen } = attributes; - - const existing = acc[appId] || { minutesOnScreen: 0, numberOfClicks: 0 }; - - return { - ...acc, - [appId]: { - appId, - numberOfClicks: numberOfClicks + existing.numberOfClicks, - minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, - }, - }; - }, existingTotals); - - await Promise.all([ - Object.entries(totals).length && - savedObjectsClient.bulkCreate( - Object.entries(totals).map(([id, entry]) => ({ - type: SAVED_OBJECTS_TOTAL_TYPE, - id, - attributes: entry, - })), - { overwrite: true } - ), - ...rawApplicationUsageTransactional.map( - ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_TRANSACTIONAL_TYPE, id) // There is no bulkDelete :( - ), - ]); - } catch (err) { - // Silent failure - } + timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe(() => + rollDailyData(logger, getSavedObjectsClient()) + ); + timer(ROLL_INDICES_START, ROLL_TOTAL_INDICES_INTERVAL).subscribe(() => + rollTotals(logger, getSavedObjectsClient()) + ); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts b/src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts deleted file mode 100644 index d917cd2454e81..0000000000000 --- a/src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts +++ /dev/null @@ -1,55 +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 { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; - -import { findAll } from './find_all'; - -describe('telemetry_application_usage', () => { - test('when savedObjectClient is initialised, return something', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) - ); - - expect(await findAll(savedObjectClient, { type: 'test-type' })).toStrictEqual([]); - }); - - test('paging in findAll works', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - let total = 201; - const doc = { id: 'test-id', attributes: { test: 1 } }; - savedObjectClient.find.mockImplementation(async (opts) => { - if ((opts.page || 1) > 2) { - return { saved_objects: [], total } as any; - } - const savedObjects = new Array(opts.perPage).fill(doc); - total = savedObjects.length * 2 + 1; - return { saved_objects: savedObjects, total }; - }); - - expect(await findAll(savedObjectClient, { type: 'test-type' })).toStrictEqual( - new Array(total - 1).fill(doc) - ); - }); -}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/find_all.ts b/src/plugins/kibana_usage_collection/server/collectors/find_all.ts deleted file mode 100644 index 5bb4f20b5c5b1..0000000000000 --- a/src/plugins/kibana_usage_collection/server/collectors/find_all.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - SavedObjectAttributes, - ISavedObjectsRepository, - SavedObjectsFindOptions, - SavedObject, -} from 'kibana/server'; - -export async function findAll( - savedObjectsClient: ISavedObjectsRepository, - opts: SavedObjectsFindOptions -): Promise>> { - const { page = 1, perPage = 10000, ...options } = opts; - const { saved_objects: savedObjects, total } = await savedObjectsClient.find({ - ...options, - page, - perPage, - }); - if (page * perPage >= total) { - return savedObjects; - } - return [...savedObjects, ...(await findAll(savedObjectsClient, { ...opts, page: page + 1 }))]; -} diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts index 46768813b1970..9c02a9cbf3204 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts @@ -23,7 +23,6 @@ import { SavedObjectsServiceSetup, } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { findAll } from '../find_all'; interface UIMetricsSavedObjects extends SavedObjectAttributes { count: number; @@ -55,9 +54,10 @@ export function registerUiMetricUsageCollector( return; } - const rawUiMetrics = await findAll(savedObjectsClient, { + const { saved_objects: rawUiMetrics } = await savedObjectsClient.find({ type: 'ui-metric', fields: ['count'], + perPage: 10000, }); const uiMetricsByAppName = rawUiMetrics.reduce((accum, rawUiMetric) => { diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index d4295c770803e..260acd19ab516 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -30,6 +30,7 @@ import { CoreStart, SavedObjectsServiceSetup, OpsMetrics, + Logger, } from '../../../core/server'; import { registerApplicationUsageCollector, @@ -47,12 +48,14 @@ interface KibanaUsageCollectionPluginsDepsSetup { type SavedObjectsRegisterType = SavedObjectsServiceSetup['registerType']; export class KibanaUsageCollectionPlugin implements Plugin { + private readonly logger: Logger; private readonly legacyConfig$: Observable; private savedObjectsClient?: ISavedObjectsRepository; private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); this.legacyConfig$ = initializerContext.config.legacy.globalConfig$; this.metric$ = new Subject(); } @@ -88,7 +91,12 @@ export class KibanaUsageCollectionPlugin implements Plugin { registerKibanaUsageCollector(usageCollection, this.legacyConfig$); registerManagementUsageCollector(usageCollection, getUiSettingsClient); registerUiMetricUsageCollector(usageCollection, registerType, getSavedObjectsClient); - registerApplicationUsageCollector(usageCollection, registerType, getSavedObjectsClient); + registerApplicationUsageCollector( + this.logger.get('application-usage'), + usageCollection, + registerType, + getSavedObjectsClient + ); registerCspCollector(usageCollection, coreSetup.http); } } diff --git a/test/api_integration/apis/telemetry/telemetry_local.js b/test/api_integration/apis/telemetry/telemetry_local.js index d2d61705b763d..9a5467e622ff3 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.js +++ b/test/api_integration/apis/telemetry/telemetry_local.js @@ -154,5 +154,113 @@ export default function ({ getService }) { expect(expected.every((m) => actual.includes(m))).to.be.ok(); }); + + describe('application usage limits', () => { + const timeRange = { + min: '2018-07-23T22:07:00Z', + max: '2018-07-23T22:13:00Z', + }; + + function createSavedObject() { + return supertest + .post('/api/saved_objects/application_usage_transactional') + .send({ + attributes: { + appId: 'test-app', + minutesOnScreen: 10.99, + numberOfClicks: 10, + timestamp: new Date().toISOString(), + }, + }) + .expect(200) + .then((resp) => resp.body.id); + } + + describe('basic behaviour', () => { + let savedObjectId; + before('create 1 entry', async () => { + return createSavedObject().then((id) => (savedObjectId = id)); + }); + after('cleanup', () => { + return supertest + .delete(`/api/saved_objects/application_usage_transactional/${savedObjectId}`) + .expect(200); + }); + + it('should return application_usage data', async () => { + const { body } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, unencrypted: true }) + .expect(200); + + expect(body.length).to.be(1); + const stats = body[0]; + expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({ + 'test-app': { + clicks_total: 10, + clicks_7_days: 10, + clicks_30_days: 10, + clicks_90_days: 10, + minutes_on_screen_total: 10.99, + minutes_on_screen_7_days: 10.99, + minutes_on_screen_30_days: 10.99, + minutes_on_screen_90_days: 10.99, + }, + }); + }); + }); + + describe('10k + 1', () => { + const savedObjectIds = []; + before('create 10k + 1 entries for application usage', async () => { + await supertest + .post('/api/saved_objects/_bulk_create') + .send( + new Array(10001).fill(0).map(() => ({ + type: 'application_usage_transactional', + attributes: { + appId: 'test-app', + minutesOnScreen: 1, + numberOfClicks: 1, + timestamp: new Date().toISOString(), + }, + })) + ) + .expect(200) + .then((resp) => resp.body.saved_objects.forEach(({ id }) => savedObjectIds.push(id))); + }); + after('clean them all', async () => { + // The SavedObjects API does not allow bulk deleting, and deleting one by one takes ages and the tests timeout + await es.deleteByQuery({ + index: '.kibana', + body: { query: { term: { type: 'application_usage_transactional' } } }, + }); + }); + + it("should only use the first 10k docs for the application_usage data (they'll be rolled up in a later process)", async () => { + const { body } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, unencrypted: true }) + .expect(200); + + expect(body.length).to.be(1); + const stats = body[0]; + expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({ + 'test-app': { + clicks_total: 10000, + clicks_7_days: 10000, + clicks_30_days: 10000, + clicks_90_days: 10000, + minutes_on_screen_total: 10000, + minutes_on_screen_7_days: 10000, + minutes_on_screen_30_days: 10000, + minutes_on_screen_90_days: 10000, + }, + }); + }); + }); + }); }); } diff --git a/test/common/services/security/security.ts b/test/common/services/security/security.ts index fae4c9198cab6..d94822611f80b 100644 --- a/test/common/services/security/security.ts +++ b/test/common/services/security/security.ts @@ -21,7 +21,7 @@ import { Role } from './role'; import { User } from './user'; import { RoleMappings } from './role_mappings'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { createTestUserService } from './test_user'; +import { createTestUserService, TestUserSupertestProvider } from './test_user'; export async function SecurityServiceProvider(context: FtrProviderContext) { const { getService } = context; @@ -31,11 +31,13 @@ export async function SecurityServiceProvider(context: FtrProviderContext) { const role = new Role(log, kibanaServer); const user = new User(log, kibanaServer); const testUser = await createTestUserService(role, user, context); + const testUserSupertest = TestUserSupertestProvider(context); return new (class SecurityService { roleMappings = new RoleMappings(log, kibanaServer); testUser = testUser; role = role; user = user; + testUserSupertest = testUserSupertest; })(); } diff --git a/test/common/services/security/test_user.ts b/test/common/services/security/test_user.ts index ed786d6ff3c34..7183943591c88 100644 --- a/test/common/services/security/test_user.ts +++ b/test/common/services/security/test_user.ts @@ -16,12 +16,18 @@ * specific language governing permissions and limitations * under the License. */ +import { format as formatUrl } from 'url'; +import supertestAsPromised from 'supertest-as-promised'; + import { Role } from './role'; import { User } from './user'; import { FtrProviderContext } from '../../ftr_provider_context'; import { Browser } from '../../../functional/services/common'; import { TestSubjects } from '../../../functional/services/common'; +const TEST_USER_NAME = 'test_user'; +const TEST_USER_PASSWORD = 'changeme'; + export async function createTestUserService( role: Role, user: User, @@ -50,15 +56,15 @@ export async function createTestUserService( } try { // delete the test_user if present (will it error if the user doesn't exist?) - await user.delete('test_user'); + await user.delete(TEST_USER_NAME); } catch (exception) { log.debug('no test user to delete'); } // create test_user with username and pwd log.debug(`default roles = ${config.get('security.defaultRoles')}`); - await user.create('test_user', { - password: 'changeme', + await user.create(TEST_USER_NAME, { + password: TEST_USER_PASSWORD, roles: config.get('security.defaultRoles'), full_name: 'test user', }); @@ -74,8 +80,8 @@ export async function createTestUserService( async setRoles(roles: string[], shouldRefreshBrowser: boolean = true) { if (isEnabled()) { log.debug(`set roles = ${roles}`); - await user.create('test_user', { - password: 'changeme', + await user.create(TEST_USER_NAME, { + password: TEST_USER_PASSWORD, roles, full_name: 'test user', }); @@ -93,3 +99,15 @@ export async function createTestUserService( } })(); } + +export function TestUserSupertestProvider({ getService }: FtrProviderContext) { + const config = getService('config'); + const kibanaServerConfig = config.get('servers.kibana'); + + return supertestAsPromised( + formatUrl({ + ...kibanaServerConfig, + auth: `${TEST_USER_NAME}:${TEST_USER_PASSWORD}`, + }) + ); +} 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/legacy/plugins/xpack_main/index.js b/x-pack/legacy/plugins/xpack_main/index.js index 854fba6624719..4931394038465 100644 --- a/x-pack/legacy/plugins/xpack_main/index.js +++ b/x-pack/legacy/plugins/xpack_main/index.js @@ -7,7 +7,7 @@ import { resolve } from 'path'; import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status'; import { setupXPackMain } from './server/lib/setup_xpack_main'; -import { xpackInfoRoute, settingsRoute } from './server/routes/api/v1'; +import { xpackInfoRoute } from './server/routes/api/v1'; export const xpackMain = (kibana) => { return new kibana.Plugin({ @@ -29,7 +29,6 @@ export const xpackMain = (kibana) => { // register routes xpackInfoRoute(server); - settingsRoute(server, this.kbnServer); }, }); }; diff --git a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/index.js b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/index.js index c0e59b4ea4ab2..80baf7bf1a64d 100644 --- a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/index.js +++ b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/index.js @@ -5,4 +5,3 @@ */ export { xpackInfoRoute } from './xpack_info'; -export { settingsRoute } from './settings'; diff --git a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js deleted file mode 100644 index 34fc4d97c1328..0000000000000 --- a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { boomify } from 'boom'; -import { get } from 'lodash'; -import { KIBANA_SETTINGS_TYPE } from '../../../../../../../plugins/monitoring/common/constants'; - -const getClusterUuid = async (callCluster) => { - const { cluster_uuid: uuid } = await callCluster('info', { filterPath: 'cluster_uuid' }); - return uuid; -}; - -export function settingsRoute(server, kbnServer) { - server.route({ - path: '/api/settings', - method: 'GET', - async handler(req) { - const { server } = req; - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - const callCluster = (...args) => callWithRequest(req, ...args); // All queries from HTTP API must use authentication headers from the request - - try { - const { usageCollection } = server.newPlatform.setup.plugins; - const settingsCollector = usageCollection.getCollectorByType(KIBANA_SETTINGS_TYPE); - - let settings = await settingsCollector.fetch(callCluster); - if (!settings) { - settings = settingsCollector.getEmailValueStructure(null); - } - const uuid = await getClusterUuid(callCluster); - - const snapshotRegex = /-snapshot/i; - const config = server.config(); - const status = kbnServer.status.toJSON(); - const kibana = { - uuid: config.get('server.uuid'), - name: config.get('server.name'), - index: config.get('kibana.index'), - host: config.get('server.host'), - port: config.get('server.port'), - locale: config.get('i18n.locale'), - transport_address: `${config.get('server.host')}:${config.get('server.port')}`, - version: kbnServer.version.replace(snapshotRegex, ''), - snapshot: snapshotRegex.test(kbnServer.version), - status: get(status, 'overall.state'), - }; - - return { - cluster_uuid: uuid, - settings: { - ...settings, - kibana, - }, - }; - } catch (err) { - req.log(['error'], err); // FIXME doesn't seem to log anything useful if ES times out - if (err.isBoom) { - return err; - } else { - return boomify(err, { statusCode: err.statusCode, message: err.message }); - } - } - }, - }); -} diff --git a/x-pack/package.json b/x-pack/package.json index 59a43b1f344c8..de0bf0d922b42 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -39,7 +39,7 @@ "@kbn/storybook": "1.0.0", "@kbn/test": "1.0.0", "@kbn/utility-types": "1.0.0", - "@mapbox/geojson-rewind": "^0.4.1", + "@mapbox/geojson-rewind": "^0.5.0", "@mapbox/mapbox-gl-draw": "^1.2.0", "@mapbox/mapbox-gl-rtl-text": "^0.2.3", "@scant/router": "^0.1.0", @@ -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/e2e/cypress/integration/helpers.ts b/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts index 1956f1c2d9f0d..462304a959102 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts +++ b/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts @@ -11,14 +11,19 @@ export const DEFAULT_TIMEOUT = 60 * 1000; export function loginAndWaitForPage( url: string, - dateRange: { to: string; from: string } + dateRange: { to: string; from: string }, + selectedService?: string ) { const username = Cypress.env('elasticsearch_username'); const password = Cypress.env('elasticsearch_password'); cy.log(`Authenticating via ${username} / ${password}`); - const fullUrl = `${BASE_URL}${url}?rangeFrom=${dateRange.from}&rangeTo=${dateRange.to}`; + let fullUrl = `${BASE_URL}${url}?rangeFrom=${dateRange.from}&rangeTo=${dateRange.to}`; + + if (selectedService) { + fullUrl += `&serviceName=${selectedService}`; + } cy.visit(fullUrl, { auth: { username, password } }); cy.viewport('macbook-15'); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index a57241a197ca4..461e2960c5e02 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -15,10 +15,14 @@ Given(`a user browses the APM UI application for RUM Data`, () => { // open service overview page const RANGE_FROM = 'now-24h'; const RANGE_TO = 'now'; - loginAndWaitForPage(`/app/csm`, { - from: RANGE_FROM, - to: RANGE_TO, - }); + loginAndWaitForPage( + `/app/csm`, + { + from: RANGE_FROM, + to: RANGE_TO, + }, + 'client' + ); }); Then(`should have correct client metrics`, () => { diff --git a/x-pack/plugins/apm/e2e/yarn.lock b/x-pack/plugins/apm/e2e/yarn.lock index 936294052aa7b..fc63189e97ea3 100644 --- a/x-pack/plugins/apm/e2e/yarn.lock +++ b/x-pack/plugins/apm/e2e/yarn.lock @@ -5494,10 +5494,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@3.9.6: - version "3.9.6" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.6.tgz#8f3e0198a34c3ae17091b35571d3afd31999365a" - integrity sha512-Pspx3oKAPJtjNwE92YS05HQoY7z2SFyOpHo9MqJor3BXAGNaPUs83CuVp9VISFkSjyRfiTpmKuAYGJB7S7hOxw== +typescript@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2" + integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ== umd@^3.0.0: version "3.0.3" diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx index f54a54211359c..1edfd724dadd7 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx @@ -26,7 +26,8 @@ export function ClientMetrics() { const { data, status } = useFetcher( (callApmApi) => { - if (start && end) { + const { serviceName } = uiFilters; + if (start && end && serviceName) { return callApmApi({ pathname: '/api/apm/rum/client-metrics', params: { diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx index d166656db0672..3bf4807877428 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx @@ -132,7 +132,7 @@ export function Waterfall({
{ setIsAccordionOpen((isOpen) => !isOpen); }} diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx index 3deba69a25df2..cbf9ba009dce2 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx @@ -24,7 +24,7 @@ interface Props { function ServiceNameFilter({ loading, serviceNames }: Props) { const history = useHistory(); const { - urlParams: { serviceName }, + urlParams: { serviceName: selectedServiceName }, } = useUrlParams(); const options = serviceNames.map((type) => ({ @@ -47,10 +47,22 @@ function ServiceNameFilter({ loading, serviceNames }: Props) { ); useEffect(() => { - if (!serviceName && serviceNames.length > 0) { - updateServiceName(serviceNames[0]); + if (serviceNames?.length > 0) { + // select first from the list + if (!selectedServiceName) { + updateServiceName(serviceNames[0]); + } + + // in case serviceName is cached from url and isn't present in current list + if (selectedServiceName && !serviceNames.includes(selectedServiceName)) { + updateServiceName(serviceNames[0]); + } + } + + if (selectedServiceName && serviceNames.length === 0 && !loading) { + updateServiceName(''); } - }, [serviceNames, serviceName, updateServiceName]); + }, [serviceNames, selectedServiceName, updateServiceName, loading]); return ( <> @@ -68,7 +80,7 @@ function ServiceNameFilter({ loading, serviceNames }: Props) { isLoading={loading} data-cy="serviceNameFilter" options={options} - value={serviceName} + value={selectedServiceName} compressed={true} onChange={(event) => { updateServiceName(event.target.value); 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/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 8ab45dc24aa17..440585dcf2a19 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -268,8 +268,13 @@ export function resetJob(jobCreator: JobCreatorType, navigateToPath: NavigateToP navigateToPath('/jobs/new_job'); } -export function advancedStartDatafeed(jobCreator: JobCreatorType, navigateToPath: NavigateToPath) { - stashCombinedJob(jobCreator, false, false); +export function advancedStartDatafeed( + jobCreator: JobCreatorType | null, + navigateToPath: NavigateToPath +) { + if (jobCreator !== null) { + stashCombinedJob(jobCreator, false, false); + } navigateToPath('/jobs'); } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/index.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/index.ts new file mode 100644 index 0000000000000..a62e378222ff9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { StartDatafeedSwitch } from './start_datafeed_switch'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/start_datafeed_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/start_datafeed_switch.tsx new file mode 100644 index 0000000000000..4aa78cfc41009 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/start_datafeed_switch.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSwitch, EuiFormRow, EuiSpacer } from '@elastic/eui'; +interface Props { + startDatafeed: boolean; + setStartDatafeed(start: boolean): void; + disabled?: boolean; +} + +export const StartDatafeedSwitch: FC = ({ + startDatafeed, + setStartDatafeed, + disabled = false, +}) => { + return ( + <> + + + setStartDatafeed(e.target.checked)} + disabled={disabled} + /> + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx index 3000ce8449138..669b8837e74b5 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx @@ -28,6 +28,7 @@ import { DatafeedDetails } from './components/datafeed_details'; import { DetectorChart } from './components/detector_chart'; import { JobProgress } from './components/job_progress'; import { PostSaveOptions } from './components/post_save_options'; +import { StartDatafeedSwitch } from './components/start_datafeed_switch'; import { toastNotificationServiceProvider } from '../../../../../services/toast_notification_service'; import { convertToAdvancedJob, @@ -50,6 +51,7 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => const [creatingJob, setCreatingJob] = useState(false); const [isValid, setIsValid] = useState(jobValidator.validationSummary.basic); const [jobRunner, setJobRunner] = useState(null); + const [startDatafeed, setStartDatafeed] = useState(true); const isAdvanced = isAdvancedJobCreator(jobCreator); const jsonEditorMode = isAdvanced ? EDITOR_MODE.EDITABLE : EDITOR_MODE.READONLY; @@ -59,15 +61,17 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => }, []); async function start() { + setCreatingJob(true); if (isAdvanced) { - await startAdvanced(); + await createAdvancedJob(); + } else if (startDatafeed === true) { + await createAndStartJob(); } else { - await startInline(); + await createAdvancedJob(false); } } - async function startInline() { - setCreatingJob(true); + async function createAndStartJob() { try { const jr = await jobCreator.createAndStartJob(); setJobRunner(jr); @@ -76,12 +80,11 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => } } - async function startAdvanced() { - setCreatingJob(true); + async function createAdvancedJob(showStartModal: boolean = true) { try { await jobCreator.createJob(); await jobCreator.createDatafeed(); - advancedStartDatafeed(jobCreator, navigateToPath); + advancedStartDatafeed(showStartModal ? jobCreator : null, navigateToPath); } catch (error) { handleJobCreationError(error); } @@ -131,6 +134,14 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => + {isAdvanced === false && ( + + )} + {isAdvanced && ( diff --git a/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts b/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts index 6a5583ecbb8ac..fa6329b4e8652 100644 --- a/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts +++ b/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts @@ -456,7 +456,7 @@ describe('ML - custom URL utils', () => { ); }); - test('return expected url for Security app', () => { + test('return expected URL for Security app', () => { const urlConfig = { url_name: 'Hosts Details by process name', url_value: @@ -508,6 +508,45 @@ describe('ML - custom URL utils', () => { ); }); + test('return expected URL for Metrics app', () => { + const urlConfig = { + url_name: 'Hosts Details by process name', + url_value: + 'metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))', + }; + + const testRecord = { + job_id: 'hosts_memory_usage', + result_type: 'record', + probability: 0.0001288876418224276, + multi_bucket_impact: -5, + record_score: 88.26287, + initial_record_score: 61.553927615180186, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1599571800000, + function: 'max', + function_description: 'max', + typical: [0.23685835059986396], + actual: [0.258], + field_name: 'system.memory.actual.used.pct', + influencers: [ + { + influencer_field_name: 'host.name', + influencer_field_values: ['gke-dev-next-oblt-dev-next-oblt-pool-404d7f0c-2bfl'], + }, + ], + 'host.name': ['gke-dev-next-oblt-dev-next-oblt-pool-404d7f0c-2bfl'], + earliest: '2019-09-08T12:00:00.000Z', + latest: '2019-09-08T14:59:59.999Z', + }; + + expect(getUrlForRecord(urlConfig, testRecord)).toBe( + "metrics/detail/host/gke-dev-next-oblt-dev-next-oblt-pool-404d7f0c-2bfl?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:'2019-09-08T12:00:00.000Z',interval:>=1m,to:'2019-09-08T14:59:59.999Z'))" + ); + }); + test('removes an empty path component with a trailing slash', () => { const urlConfig = { url_name: 'APM', diff --git a/x-pack/plugins/ml/public/application/util/custom_url_utils.ts b/x-pack/plugins/ml/public/application/util/custom_url_utils.ts index 18ba1e4ee337b..cf24fdb22a651 100644 --- a/x-pack/plugins/ml/public/application/util/custom_url_utils.ts +++ b/x-pack/plugins/ml/public/application/util/custom_url_utils.ts @@ -117,6 +117,7 @@ function isKibanaUrl(urlConfig: UrlConfig) { urlValue.startsWith('dashboards#/') || urlValue.startsWith('apm#/') || // BrowserRouter based plugins + urlValue.startsWith('metrics/') || urlValue.startsWith('security/') || // Legacy links urlValue.startsWith('siem#/') diff --git a/x-pack/plugins/monitoring/server/index.ts b/x-pack/plugins/monitoring/server/index.ts index 60f04c535ebf1..de679a2834d7b 100644 --- a/x-pack/plugins/monitoring/server/index.ts +++ b/x-pack/plugins/monitoring/server/index.ts @@ -10,6 +10,7 @@ import { Plugin } from './plugin'; import { configSchema } from './config'; import { deprecations } from './deprecations'; +export { KibanaSettingsCollector } from './kibana_monitoring/collectors'; export { MonitoringConfig } from './config'; export const plugin = (initContext: PluginInitializerContext) => new Plugin(initContext); export const config: PluginConfigDescriptor> = { diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts index c66adfcabd671..a3ff4b952ce97 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Collector } from '../../../../../../src/plugins/usage_collection/server'; + import { KIBANA_SETTINGS_TYPE } from '../../../common/constants'; import { MonitoringConfig } from '../../config'; @@ -38,11 +40,19 @@ export async function checkForEmailValue( } } +interface EmailSettingData { + xpack: { default_admin_email: string | null }; +} + +export interface KibanaSettingsCollector extends Collector { + getEmailValueStructure(email: string | null): EmailSettingData; +} + export function getSettingsCollector(usageCollection: any, config: MonitoringConfig) { return usageCollection.makeStatsCollector({ type: KIBANA_SETTINGS_TYPE, isReady: () => true, - async fetch() { + async fetch(this: KibanaSettingsCollector) { let kibanaSettingsData; const defaultAdminEmail = await checkForEmailValue(config); @@ -64,7 +74,7 @@ export function getSettingsCollector(usageCollection: any, config: MonitoringCon // returns undefined if there was no result return kibanaSettingsData; }, - getEmailValueStructure(email: string) { + getEmailValueStructure(email: string | null) { return { xpack: { default_admin_email: email, diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts index dcd35b0d323eb..aa4853ab226f4 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts @@ -8,6 +8,8 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { getSettingsCollector } from './get_settings_collector'; import { MonitoringConfig } from '../../config'; +export { KibanaSettingsCollector } from './get_settings_collector'; + export function registerCollectors( usageCollection: UsageCollectionSetup, config: MonitoringConfig diff --git a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx index 02e841ec50ee2..b430b7d5959fe 100644 --- a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx @@ -11,6 +11,7 @@ import { EuiIconTip, EuiLink, EuiText, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; @@ -80,13 +81,14 @@ export function AlertsSection({ alerts }: Props) { const isLastElement = index === alerts.length - 1; return ( + - {alert.name} + {alert.name} 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/ecs/agent/index.ts b/x-pack/plugins/security_solution/common/ecs/agent/index.ts new file mode 100644 index 0000000000000..6f29a2020c944 --- /dev/null +++ b/x-pack/plugins/security_solution/common/ecs/agent/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface AgentEcs { + type?: string[]; +} diff --git a/x-pack/plugins/security_solution/common/ecs/endgame/index.ts b/x-pack/plugins/security_solution/common/ecs/endgame/index.ts index f435db4f47810..d2fc5d61527a5 100644 --- a/x-pack/plugins/security_solution/common/ecs/endgame/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/endgame/index.ts @@ -5,29 +5,17 @@ */ export interface EndgameEcs { - exit_code?: number; - - file_name?: string; - - file_path?: string; - - logon_type?: number; - - parent_process_name?: string; - - pid?: number; - - process_name?: string; - - subject_domain_name?: string; - - subject_logon_id?: string; - - subject_user_name?: string; - - target_domain_name?: string; - - target_logon_id?: string; - - target_user_name?: string; + exit_code?: number[]; + file_name?: string[]; + file_path?: string[]; + logon_type?: number[]; + parent_process_name?: string[]; + pid?: number[]; + process_name?: string[]; + subject_domain_name?: string[]; + subject_logon_id?: string[]; + subject_user_name?: string[]; + target_domain_name?: string[]; + target_logon_id?: string[]; + target_user_name?: string[]; } diff --git a/x-pack/plugins/security_solution/common/ecs/index.ts b/x-pack/plugins/security_solution/common/ecs/index.ts index e31d42b02f80b..b8190463f5da5 100644 --- a/x-pack/plugins/security_solution/common/ecs/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/index.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AgentEcs } from './agent'; import { AuditdEcs } from './auditd'; import { DestinationEcs } from './destination'; import { DnsEcs } from './dns'; import { EndgameEcs } from './endgame'; import { EventEcs } from './event'; +import { FileEcs } from './file'; import { GeoEcs } from './geo'; import { HostEcs } from './host'; import { NetworkEcs } from './network'; @@ -28,6 +30,7 @@ import { SystemEcs } from './system'; export interface Ecs { _id: string; _index?: string; + agent?: AgentEcs; auditd?: AuditdEcs; destination?: DestinationEcs; dns?: DnsEcs; @@ -49,6 +52,6 @@ export interface Ecs { user?: UserEcs; winlog?: WinlogEcs; process?: ProcessEcs; - file?: File; + file?: FileEcs; system?: SystemEcs; } diff --git a/x-pack/plugins/security_solution/common/ecs/process/index.ts b/x-pack/plugins/security_solution/common/ecs/process/index.ts index 0584d95c8059d..451f1455f55d4 100644 --- a/x-pack/plugins/security_solution/common/ecs/process/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/process/index.ts @@ -5,35 +5,25 @@ */ export interface ProcessEcs { + entity_id?: string[]; hash?: ProcessHashData; - pid?: number[]; - name?: string[]; - ppid?: number[]; - args?: string[]; - executable?: string[]; - title?: string[]; - thread?: Thread; - working_directory?: string[]; } export interface ProcessHashData { md5?: string[]; - sha1?: string[]; - sha256?: string[]; } export interface Thread { id?: number[]; - start?: string[]; } diff --git a/x-pack/plugins/security_solution/common/ecs/rule/index.ts b/x-pack/plugins/security_solution/common/ecs/rule/index.ts index c1ef1ee17ca0c..47316c7791e4b 100644 --- a/x-pack/plugins/security_solution/common/ecs/rule/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/rule/index.ts @@ -6,64 +6,38 @@ export interface RuleEcs { id?: string[]; - rule_id?: string[]; - false_positives: string[]; - saved_id?: string[]; - timeline_id?: string[]; - timeline_title?: string[]; - max_signals?: number[]; - risk_score?: string[]; - output_index?: string[]; - description?: string[]; - from?: string[]; - immutable?: boolean[]; - index?: string[]; - interval?: string[]; - language?: string[]; - query?: string[]; - references?: string[]; - severity?: string[]; - tags?: string[]; - threat?: unknown; - + threshold?: { + field: string; + value: number; + }; type?: string[]; - size?: string[]; - to?: string[]; - enabled?: boolean[]; - filters?: unknown; - created_at?: string[]; - updated_at?: string[]; - created_by?: string[]; - updated_by?: string[]; - version?: string[]; - note?: string[]; } diff --git a/x-pack/plugins/security_solution/common/ecs/signal/index.ts b/x-pack/plugins/security_solution/common/ecs/signal/index.ts index 66e35e26af341..55a889f3a5dd1 100644 --- a/x-pack/plugins/security_solution/common/ecs/signal/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/signal/index.ts @@ -8,6 +8,6 @@ import { RuleEcs } from '../rule'; export interface SignalEcs { rule?: RuleEcs; - original_time?: string[]; + status?: string[]; } diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index a6018837fa4fe..74ccf9105ba6b 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -8,11 +8,13 @@ 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'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; +export const TRUSTED_APPS_SUPPORTED_OS_TYPES: readonly string[] = ['macos', 'windows', 'linux']; export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index 07208214a641a..9634659b1a5dd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -3,20 +3,24 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - LegacyEndpointEvent, - ResolverEvent, - SafeResolverEvent, - SafeLegacyEndpointEvent, -} from '../types'; +import { LegacyEndpointEvent, ResolverEvent, SafeResolverEvent, ECSField } from '../types'; import { firstNonNullValue, hasValue, values } from './ecs_safety_helpers'; +/** + * Legacy events will define the `endgame` object. This is used to narrow a ResolverEvent. + */ +interface LegacyEvent { + endgame?: object; +} + /* - * Determine if a `ResolverEvent` is the legacy variety. Can be used to narrow `ResolverEvent` to `LegacyEndpointEvent`. + * Determine if a higher level event type is the legacy variety. Can be used to narrow an event type. + * T optionally defines an `endgame` object field used for determining the type of event. If T doesn't contain the + * `endgame` field it will serve as the narrowed type. */ -export function isLegacyEventSafeVersion( - event: SafeResolverEvent -): event is SafeLegacyEndpointEvent { +export function isLegacyEventSafeVersion( + event: LegacyEvent | {} +): event is T { return 'endgame' in event && event.endgame !== undefined; } @@ -27,7 +31,30 @@ export function isLegacyEvent(event: ResolverEvent): event is LegacyEndpointEven return (event as LegacyEndpointEvent).endgame !== undefined; } -export function isProcessRunning(event: SafeResolverEvent): boolean { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type ProcessRunningFields = Partial< + | { + endgame: object; + event: Partial<{ + type: ECSField; + action: ECSField; + }>; + } + | { + event: Partial<{ + type: ECSField; + }>; + } +>; + +/** + * Checks if an event describes a process as running (whether it was started, already running, or changed) + * + * @param event a document to check for running fields + */ +export function isProcessRunning(event: ProcessRunningFields): boolean { if (isLegacyEventSafeVersion(event)) { return ( hasValue(event.event?.type, 'process_start') || @@ -43,7 +70,18 @@ export function isProcessRunning(event: SafeResolverEvent): boolean { ); } -export function timestampSafeVersion(event: SafeResolverEvent): undefined | number { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type TimestampFields = Pick; + +/** + * Extracts the first non null value from the `@timestamp` field in the document. Returns undefined if the field doesn't + * exist in the document. + * + * @param event a document from ES + */ +export function timestampSafeVersion(event: TimestampFields): undefined | number { return firstNonNullValue(event?.['@timestamp']); } @@ -51,7 +89,7 @@ export function timestampSafeVersion(event: SafeResolverEvent): undefined | numb * The `@timestamp` for the event, as a `Date` object. * If `@timestamp` couldn't be parsed as a `Date`, returns `undefined`. */ -export function timestampAsDateSafeVersion(event: SafeResolverEvent): Date | undefined { +export function timestampAsDateSafeVersion(event: TimestampFields): Date | undefined { const value = timestampSafeVersion(event); if (value === undefined) { return undefined; @@ -93,9 +131,30 @@ export function eventId(event: ResolverEvent): number | undefined | string { return event.event.id; } -export function eventSequence(event: SafeResolverEvent): number | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type EventSequenceFields = Partial< + | { + endgame: Partial<{ + serial_event_id: ECSField; + }>; + } + | { + event: Partial<{ + sequence: ECSField; + }>; + } +>; + +/** + * Extract the first non null event sequence value from a document. Returns undefined if the field doesn't exist in the document. + * + * @param event a document from ES + */ +export function eventSequence(event: EventSequenceFields): number | undefined { if (isLegacyEventSafeVersion(event)) { - return firstNonNullValue(event.endgame.serial_event_id); + return firstNonNullValue(event.endgame?.serial_event_id); } return firstNonNullValue(event.event?.sequence); } @@ -113,7 +172,29 @@ export function entityId(event: ResolverEvent): string { return event.process.entity_id; } -export function entityIDSafeVersion(event: SafeResolverEvent): string | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type EntityIDFields = Partial< + | { + endgame: Partial<{ + unique_pid: ECSField; + }>; + } + | { + process: Partial<{ + entity_id: ECSField; + }>; + } +>; + +/** + * Extract the first non null value from either the `entity_id` or `unique_pid` depending on the document type. Returns + * undefined if the field doesn't exist in the document. + * + * @param event a document from ES + */ +export function entityIDSafeVersion(event: EntityIDFields): string | undefined { if (isLegacyEventSafeVersion(event)) { return event.endgame?.unique_pid === undefined ? undefined @@ -130,14 +211,59 @@ export function parentEntityId(event: ResolverEvent): string | undefined { return event.process.parent?.entity_id; } -export function parentEntityIDSafeVersion(event: SafeResolverEvent): string | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type ParentEntityIDFields = Partial< + | { + endgame: Partial<{ + unique_ppid: ECSField; + }>; + } + | { + process: Partial<{ + parent: Partial<{ + entity_id: ECSField; + }>; + }>; + } +>; + +/** + * Extract the first non null value from either the `parent.entity_id` or `unique_ppid` depending on the document type. Returns + * undefined if the field doesn't exist in the document. + * + * @param event a document from ES + */ +export function parentEntityIDSafeVersion(event: ParentEntityIDFields): string | undefined { if (isLegacyEventSafeVersion(event)) { - return String(firstNonNullValue(event.endgame.unique_ppid)); + return String(firstNonNullValue(event.endgame?.unique_ppid)); } return firstNonNullValue(event.process?.parent?.entity_id); } -export function ancestryArray(event: SafeResolverEvent): string[] | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type AncestryArrayFields = Partial< + | { + endgame: object; + } + | { + process: Partial<{ + Ext: Partial<{ + ancestry: ECSField; + }>; + }>; + } +>; + +/** + * Extracts all ancestry array from a document if it exists. + * + * @param event an ES document + */ +export function ancestryArray(event: AncestryArrayFields): string[] | undefined { if (isLegacyEventSafeVersion(event)) { return undefined; } @@ -146,7 +272,17 @@ export function ancestryArray(event: SafeResolverEvent): string[] | undefined { return values(event.process?.Ext?.ancestry); } -export function getAncestryAsArray(event: SafeResolverEvent | undefined): string[] { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type GetAncestryArrayFields = AncestryArrayFields & ParentEntityIDFields; + +/** + * Returns an array of strings representing the ancestry for a process. + * + * @param event an ES document + */ +export function getAncestryAsArray(event: GetAncestryArrayFields | undefined): string[] { if (!event) { return []; } diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index b0c769216732d..fc94e9a7c312a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -76,7 +76,7 @@ describe('When invoking Trusted Apps Schema', () => { os: 'windows', entries: [ { - field: 'path', + field: 'process.path', type: 'match', operator: 'included', value: 'c:/programs files/Anti-Virus', @@ -111,14 +111,6 @@ describe('When invoking Trusted Apps Schema', () => { expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); }); - it('should validate `description` to be non-empty if defined', () => { - const bodyMsg = { - ...getCreateTrustedAppItem(), - description: '', - }; - expect(() => body.validate(bodyMsg)).toThrow(); - }); - it('should validate `os` to to only accept known values', () => { const bodyMsg = { ...getCreateTrustedAppItem(), @@ -202,7 +194,7 @@ describe('When invoking Trusted Apps Schema', () => { }; expect(() => body.validate(bodyMsg2)).toThrow(); - ['hash', 'path'].forEach((field) => { + ['process.hash.*', 'process.path'].forEach((field) => { const bodyMsg3 = { ...getCreateTrustedAppItem(), entries: [ @@ -217,9 +209,55 @@ describe('When invoking Trusted Apps Schema', () => { }); }); - it.todo('should validate `entry.type` is limited to known values'); + it('should validate `entry.type` is limited to known values', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + type: 'invalid', + }, + ], + }; + expect(() => body.validate(bodyMsg)).toThrow(); + + // Allow `match` + const bodyMsg2 = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + type: 'match', + }, + ], + }; + expect(() => body.validate(bodyMsg2)).not.toThrow(); + }); + + it('should validate `entry.operator` is limited to known values', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + operator: 'invalid', + }, + ], + }; + expect(() => body.validate(bodyMsg)).toThrow(); - it.todo('should validate `entry.operator` is limited to known values'); + // Allow `match` + const bodyMsg2 = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + operator: 'included', + }, + ], + }; + expect(() => body.validate(bodyMsg2)).not.toThrow(); + }); it('should validate `entry.value` required', () => { const { value, ...entry } = getTrustedAppItemEntryItem(); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 7c0de84b637c9..72e24a7d694d4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -22,11 +22,11 @@ export const GetTrustedAppsRequestSchema = { export const PostTrustedAppCreateRequestSchema = { body: schema.object({ name: schema.string({ minLength: 1 }), - description: schema.maybe(schema.string({ minLength: 1 })), + description: schema.maybe(schema.string({ minLength: 0, defaultValue: '' })), os: schema.oneOf([schema.literal('linux'), schema.literal('macos'), schema.literal('windows')]), entries: schema.arrayOf( schema.object({ - field: schema.oneOf([schema.literal('hash'), schema.literal('path')]), + field: schema.oneOf([schema.literal('process.hash.*'), schema.literal('process.path')]), type: schema.literal('match'), operator: schema.literal('included'), value: schema.string({ minLength: 1 }), 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/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 7aeb6c6024b99..3356fc67d2682 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -25,17 +25,17 @@ export interface PostTrustedAppCreateResponse { data: TrustedApp; } -interface MacosLinuxConditionEntry { - field: 'hash' | 'path'; +export interface MacosLinuxConditionEntry { + field: 'process.hash.*' | 'process.path'; type: 'match'; operator: 'included'; value: string; } -type WindowsConditionEntry = +export type WindowsConditionEntry = | MacosLinuxConditionEntry | (Omit & { - field: 'signer'; + field: 'process.code_signature'; }); /** Type for a new Trusted App Entry */ diff --git a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts index b55226b08b800..48437e12f75a5 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts @@ -113,3 +113,13 @@ export interface GenericBuckets { } export type StringOrNumber = string | number; + +export interface TimerangeFilter { + range: { + [timestamp: string]: { + gte: string; + lte: string; + format: string; + }; + }; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts new file mode 100644 index 0000000000000..0503a9c327467 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { Ecs } from '../../../../ecs'; +import { CursorType, Inspect, Maybe } from '../../../common'; +import { TimelineRequestOptionsPaginated } from '../..'; + +export interface TimelineEdges { + node: TimelineItem; + cursor: CursorType; +} + +export interface TimelineItem { + _id: string; + _index?: Maybe; + data: TimelineNonEcsData[]; + ecs: Ecs; +} + +export interface TimelineNonEcsData { + field: string; + value?: Maybe; +} + +export interface TimelineEventsAllStrategyResponse extends IEsSearchResponse { + edges: TimelineEdges[]; + totalCount: number; + pageInfo: { + activePage: number; + totalPages: number; + }; + inspect?: Maybe; +} + +export interface TimelineEventsAllRequestOptions extends TimelineRequestOptionsPaginated { + fields: string[]; + fieldRequested: string[]; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts new file mode 100644 index 0000000000000..200f400ef6816 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { Ecs } from '../../../../ecs'; +import { CursorType, Maybe } from '../../../common'; + +export interface TimelineEdges { + node: TimelineItem; + cursor: CursorType; +} + +export interface TimelineItem { + _id: string; + _index?: Maybe; + data: TimelineNonEcsData[]; + ecs: Ecs; +} + +export interface TimelineNonEcsData { + field: string; + value?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/details/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts similarity index 50% rename from x-pack/plugins/security_solution/common/search_strategy/timeline/details/index.ts rename to x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts index e5e1c41f4731a..6f9192be40150 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/details/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts @@ -4,25 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; -import { Inspect, Maybe } from '../../common'; -import { TimelineRequestOptionsPaginated } from '..'; +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../common'; +import { TimelineRequestOptionsPaginated } from '../..'; -export interface DetailItem { +export interface TimelineEventsDetailsItem { field: string; values?: Maybe; // eslint-disable-next-line @typescript-eslint/no-explicit-any originalValue?: Maybe; } -export interface TimelineDetailsStrategyResponse extends IEsSearchResponse { - data?: Maybe; +export interface TimelineEventsDetailsStrategyResponse extends IEsSearchResponse { + data?: Maybe; inspect?: Maybe; } -export interface TimelineDetailsRequestOptions extends Partial { +export interface TimelineEventsDetailsRequestOptions + extends Partial { defaultIndex: string[]; - executeQuery: boolean; indexName: string; eventId: string; } diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts new file mode 100644 index 0000000000000..6bb9461995974 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './all'; +export * from './details'; +export * from './last_event_time'; + +export enum TimelineEventsQueries { + all = 'eventsAll', + details = 'eventsDetails', + lastEventTime = 'eventsLastEventTime', +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts new file mode 100644 index 0000000000000..10750503fc807 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../common'; +import { TimelineRequestBasicOptions } from '../..'; + +export enum LastEventIndexKey { + hostDetails = 'hostDetails', + hosts = 'hosts', + ipDetails = 'ipDetails', + network = 'network', +} + +export interface LastTimeDetails { + hostName?: Maybe; + ip?: Maybe; +} + +export interface TimelineEventsLastEventTimeStrategyResponse extends IEsSearchResponse { + lastSeen: Maybe; + inspect?: Maybe; +} + +export interface TimelineEventsLastEventTimeRequestOptions + extends Omit { + indexKey: LastEventIndexKey; + details: LastTimeDetails; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts index a7bf61c102cd4..773ee60855886 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts @@ -3,45 +3,22 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { IEsSearchRequest } from '../../../../../../src/plugins/data/common'; import { ESQuery } from '../../typed_json'; -import { Ecs } from '../../ecs'; import { - CursorType, - Maybe, - TimerangeInput, - DocValueFields, - PaginationInput, - PaginationInputPaginated, - SortField, -} from '../common'; -import { TimelineDetailsRequestOptions, TimelineDetailsStrategyResponse } from './details'; - -export * from './details'; + TimelineEventsQueries, + TimelineEventsAllRequestOptions, + TimelineEventsAllStrategyResponse, + TimelineEventsDetailsRequestOptions, + TimelineEventsDetailsStrategyResponse, + TimelineEventsLastEventTimeRequestOptions, + TimelineEventsLastEventTimeStrategyResponse, +} from './events'; +import { DocValueFields, TimerangeInput, SortField } from '../common'; -export enum TimelineQueries { - details = 'details', -} +export * from './events'; -export type TimelineFactoryQueryTypes = TimelineQueries; - -export interface TimelineEdges { - node: TimelineItem; - cursor: CursorType; -} - -export interface TimelineItem { - _id: string; - _index?: Maybe; - data: TimelineNonEcsData[]; - ecs: Ecs; -} - -export interface TimelineNonEcsData { - field: string; - value?: Maybe; -} +export type TimelineFactoryQueryTypes = TimelineEventsQueries; export interface TimelineRequestBasicOptions extends IEsSearchRequest { timerange: TimerangeInput; @@ -51,20 +28,39 @@ export interface TimelineRequestBasicOptions extends IEsSearchRequest { factoryQueryType?: TimelineFactoryQueryTypes; } -export interface TimelineRequestOptions extends TimelineRequestBasicOptions { - pagination: PaginationInput; - sortField?: SortField; +export interface TimelineRequestOptions extends TimelineRequestBasicOptions { + pagination: { + activePage: number; + querySize: number; + }; + sort: SortField; } -export interface TimelineRequestOptionsPaginated extends TimelineRequestBasicOptions { - pagination: PaginationInputPaginated; - sortField?: SortField; +export interface TimelineRequestOptionsPaginated + extends TimelineRequestBasicOptions { + pagination: { + activePage: number; + querySize: number; + }; + sort: SortField; } export type TimelineStrategyResponseType< T extends TimelineFactoryQueryTypes -> = T extends TimelineQueries.details ? TimelineDetailsStrategyResponse : never; +> = T extends TimelineEventsQueries.all + ? TimelineEventsAllStrategyResponse + : T extends TimelineEventsQueries.details + ? TimelineEventsDetailsStrategyResponse + : T extends TimelineEventsQueries.lastEventTime + ? TimelineEventsLastEventTimeStrategyResponse + : never; export type TimelineStrategyRequestType< T extends TimelineFactoryQueryTypes -> = T extends TimelineQueries.details ? TimelineDetailsRequestOptions : never; +> = T extends TimelineEventsQueries.all + ? TimelineEventsAllRequestOptions + : T extends TimelineEventsQueries.details + ? TimelineEventsDetailsRequestOptions + : T extends TimelineEventsQueries.lastEventTime + ? TimelineEventsLastEventTimeRequestOptions + : never; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/translations.ts b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/translations.ts index b1ab509417fe5..980ec89f524bc 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/translations.ts @@ -16,7 +16,7 @@ export const ALERTS_DOCUMENT_TYPE = i18n.translate( export const TOTAL_COUNT_OF_ALERTS = i18n.translate( 'xpack.securitySolution.alertsView.totalCountOfAlerts', { - defaultMessage: 'external alerts match the search criteria', + defaultMessage: 'external alerts', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 8068d51a80153..074e6faf80c7d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; -import { DetailItem } from '../../../../common/search_strategy/timeline'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; import { EventFieldsBrowser } from './event_fields_browser'; @@ -28,7 +28,7 @@ CollapseLink.displayName = 'CollapseLink'; interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; - data: DetailItem[]; + data: TimelineEventsDetailsItem[]; id: string; view: View; onEventToggled: () => void; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx index 9737a09c89f49..79250ae9bec52 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx @@ -10,7 +10,7 @@ import React, { useMemo } from 'react'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { BrowserFields, getAllFieldsByName } from '../../containers/source'; -import { DetailItem } from '../../../../common/search_strategy/timeline'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; import { getColumns } from './columns'; @@ -19,7 +19,7 @@ import { search } from './helpers'; interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; - data: DetailItem[]; + data: TimelineEventsDetailsItem[]; eventId: string; onUpdateColumns: OnUpdateColumns; timelineId: string; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx index f4028c988acb8..bb74935d5703e 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useState } from 'react'; import { BrowserFields } from '../../containers/source'; -import { DetailItem } from '../../../../common/search_strategy/timeline'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; @@ -16,7 +16,7 @@ import { EventDetails, View } from './event_details'; interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; - data: DetailItem[]; + data: TimelineEventsDetailsItem[]; id: string; onEventToggled: () => void; onUpdateColumns: OnUpdateColumns; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 833688ae57993..037655f594241 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { MockedProvider } from 'react-apollo/test-utils'; import useResizeObserver from 'use-resize-observer/polyfilled'; import '../../mock/match_media'; @@ -26,6 +25,11 @@ import { TimelineId } from '../../../../common/types/timeline'; import { KqlMode } from '../../../timelines/store/timeline/model'; import { SortDirection } from '../../../timelines/components/timeline/body/sort'; import { AlertsTableFilterGroup } from '../../../detections/components/alerts_table/alerts_filter_group'; +import { useTimelineEvents } from '../../../timelines/containers'; + +jest.mock('../../../timelines/containers', () => ({ + useTimelineEvents: jest.fn(), +})); jest.mock('../../components/url_state/normalize_time_range.ts'); @@ -83,20 +87,19 @@ describe('EventsViewer', () => { const mount = useMountAppended(); beforeEach(() => { + (useTimelineEvents as jest.Mock).mockReturnValue([false, mockEventViewerResponse]); mockUseFetchIndexPatterns.mockImplementation(() => [{ ...defaultMocks }]); }); test('it renders the "Showing..." subtitle with the expected event count', async () => { const wrapper = mount( - - - + ); @@ -114,14 +117,12 @@ describe('EventsViewer', () => { const wrapper = mount( - - - + ); @@ -139,14 +140,12 @@ describe('EventsViewer', () => { const wrapper = mount( - - - + ); @@ -164,14 +163,12 @@ describe('EventsViewer', () => { const wrapper = mount( - - - + ); @@ -187,14 +184,12 @@ describe('EventsViewer', () => { test('it renders the Fields Browser as a settings gear', async () => { const wrapper = mount( - - - + ); @@ -208,21 +203,19 @@ describe('EventsViewer', () => { test('it renders the footer containing the Load More button', async () => { const wrapper = mount( - - - + ); await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="TimelineMoreButton"]`).first().exists()).toBe(true); + expect(wrapper.find(`[data-test-subj="timeline-pagination"]`).first().exists()).toBe(true); }); }); @@ -230,14 +223,12 @@ describe('EventsViewer', () => { test(`it renders the ${header.id} default EventsViewer column header`, async () => { const wrapper = mount( - - - + ); @@ -257,13 +248,11 @@ describe('EventsViewer', () => { test('it renders the provided headerFilterGroup', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -277,13 +266,11 @@ describe('EventsViewer', () => { test('it has a visible HeaderFilterGroupWrapper when Resolver is NOT showing, because graphEventId is undefined', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -299,13 +286,11 @@ describe('EventsViewer', () => { test('it has a visible HeaderFilterGroupWrapper when Resolver is NOT showing, because graphEventId is an empty string', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -321,13 +306,11 @@ describe('EventsViewer', () => { test('it does NOT have a visible HeaderFilterGroupWrapper when Resolver is showing, because graphEventId is a valid id', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -343,13 +326,11 @@ describe('EventsViewer', () => { test('it (still) renders an invisible headerFilterGroup (to maintain state while hidden) when Resolver is showing, because graphEventId is a valid id', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -365,9 +346,7 @@ describe('EventsViewer', () => { test('it renders the provided utilityBar when Resolver is NOT showing, because graphEventId is undefined', async () => { const wrapper = mount( - - - + ); @@ -381,9 +360,7 @@ describe('EventsViewer', () => { test('it renders the provided utilityBar when Resolver is NOT showing, because graphEventId is an empty string', async () => { const wrapper = mount( - - - + ); @@ -397,9 +374,7 @@ describe('EventsViewer', () => { test('it does NOT render the provided utilityBar when Resolver is showing, because graphEventId is a valid id', async () => { const wrapper = mount( - - - + ); @@ -415,9 +390,7 @@ describe('EventsViewer', () => { test('it renders the inspect button when Resolver is NOT showing, because graphEventId is undefined', async () => { const wrapper = mount( - - - + ); @@ -431,9 +404,7 @@ describe('EventsViewer', () => { test('it renders the inspect button when Resolver is NOT showing, because graphEventId is an empty string', async () => { const wrapper = mount( - - - + ); @@ -447,9 +418,7 @@ describe('EventsViewer', () => { test('it does NOT render the inspect button when Resolver is showing, because graphEventId is a valid id', async () => { const wrapper = mount( - - - + ); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 3d193856a8ae4..2998bd031d674 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -10,9 +10,9 @@ import React, { useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { Direction } from '../../../../common/search_strategy'; import { BrowserFields, DocValueFields } from '../../containers/source'; -import { TimelineQuery } from '../../../timelines/containers'; -import { Direction } from '../../../graphql/types'; +import { useTimelineEvents } from '../../../timelines/containers'; import { useKibana } from '../../lib/kibana'; import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model'; import { HeaderSection } from '../header_section'; @@ -196,14 +196,51 @@ const EventsViewerComponent: React.FC = ({ ), [columnsHeader, queryFields] ); + const sortField = useMemo( () => ({ - sortFieldId: sort.columnId, + field: sort.columnId, direction: sort.sortDirection as Direction, }), [sort.columnId, sort.sortDirection] ); + const [ + loading, + { events, updatedAt, inspect, loadPage, pageInfo, refetch, totalCount = 0 }, + ] = useTimelineEvents({ + docValueFields, + fields, + filterQuery: combinedQueries!.filterQuery, + id, + indexPattern, + limit: itemsPerPage, + sort: sortField, + startDate: start, + endDate: end, + skip: !canQueryTimeline, + }); + + const totalCountMinusDeleted = useMemo( + () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), + [deletedEventIds.length, totalCount] + ); + + const subtitle = useMemo( + () => + `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit(totalCountMinusDeleted)}`, + [totalCountMinusDeleted, unit] + ); + + const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [ + deletedEventIds, + events, + ]); + + useEffect(() => { + setIsQueryLoading(loading); + }, [loading]); + return ( = ({ > {canQueryTimeline ? ( - - {({ - events, - getUpdatedAt, - inspect, - loading, - loadMore, - pageInfo, - refetch, - totalCount = 0, - }) => { - setIsQueryLoading(loading); - const totalCountMinusDeleted = - totalCount > 0 ? totalCount - deletedEventIds.length : 0; - - const subtitle = `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit( - totalCountMinusDeleted - )}`; - - return ( - <> - - {headerFilterGroup && ( - - {headerFilterGroup} - - )} - - {utilityBar && !resolverIsShowing(graphEventId) && ( - {utilityBar?.(refetch, totalCountMinusDeleted)} - )} - - + <> + + {headerFilterGroup && ( + + {headerFilterGroup} + + )} + + {utilityBar && !resolverIsShowing(graphEventId) && ( + {utilityBar?.(refetch, totalCountMinusDeleted)} + )} + + - !deletedEventIds.includes(e._id))} - docValueFields={docValueFields} - id={id} - isEventViewer={true} - refetch={refetch} - sort={sort} - toggleColumn={toggleColumn} - /> + - { - /** Hide the footer if Resolver is showing. */ - !graphEventId && ( -