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..aa7c0968c4920 100644 --- a/package.json +++ b/package.json @@ -77,28 +77,30 @@ "url": "https://github.com/elastic/kibana.git" }, "resolutions": { - "**/@types/node": ">=10.17.17 <10.20.0", - "**/@types/react": "^16.9.36", - "**/@types/hapi": "^17.0.18", "**/@types/angular": "^1.6.56", - "**/@types/hoist-non-react-statics": "^3.3.1", "**/@types/chai": "^4.2.11", + "**/@types/hapi": "^17.0.18", + "**/@types/hoist-non-react-statics": "^3.3.1", + "**/@types/node": ">=10.17.17 <10.20.0", + "**/@types/react": "^16.9.36", + "**/cross-fetch/node-fetch": "^2.6.1", "**/cypress/@types/lodash": "^4.14.159", "**/cypress/lodash": "^4.17.20", - "**/typescript": "4.0.2", + "**/deepmerge": "^4.2.2", + "**/fast-deep-equal": "^3.1.1", "**/graphql-toolkit/lodash": "^4.17.15", "**/hoist-non-react-statics": "^3.3.2", + "**/image-diff/gm/debug": "^2.6.9", + "**/isomorphic-fetch/node-fetch": "^2.6.1", "**/isomorphic-git/**/base64-js": "^1.2.1", "**/istanbul-instrumenter-loader/schema-utils": "1.0.0", - "**/image-diff/gm/debug": "^2.6.9", "**/load-grunt-config/lodash": "^4.17.20", "**/node-jose/node-forge": "^0.10.0", - "**/react-dom": "^16.12.0", "**/react": "^16.12.0", + "**/react-dom": "^16.12.0", "**/react-test-renderer": "^16.12.0", "**/request": "^2.88.2", - "**/deepmerge": "^4.2.2", - "**/fast-deep-equal": "^3.1.1" + "**/typescript": "4.0.2" }, "workspaces": { "packages": [ @@ -194,7 +196,7 @@ "moment": "^2.24.0", "moment-timezone": "^0.5.27", "mustache": "2.3.2", - "node-fetch": "1.7.3", + "node-fetch": "2.6.1", "node-forge": "^0.10.0", "opn": "^5.5.0", "oppsy": "^2.0.0", diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index dabf11fdd0b66..52ef3fe05e751 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -14,7 +14,7 @@ "execa": "^4.0.2", "getopts": "^2.2.4", "glob": "^7.1.2", - "node-fetch": "^2.6.0", + "node-fetch": "^2.6.1", "simple-git": "^1.91.0", "tar-fs": "^2.1.0", "tree-kill": "^1.2.2", diff --git a/src/core/server/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/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..94e072eebec51 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -101,7 +101,7 @@ "@types/mocha": "^7.0.2", "@types/nock": "^10.0.3", "@types/node": ">=10.17.17 <10.20.0", - "@types/node-fetch": "^2.5.0", + "@types/node-fetch": "^2.5.7", "@types/nodemailer": "^6.2.1", "@types/object-hash": "^1.3.0", "@types/papaparse": "^5.0.3", @@ -209,7 +209,6 @@ "mochawesome-merge": "^4.1.0", "mustache": "^2.3.0", "mutation-observer": "^1.0.3", - "node-fetch": "^2.6.0", "null-loader": "^3.0.0", "oboe": "^2.1.4", "pixelmatch": "^5.1.0", @@ -345,7 +344,7 @@ "moment-timezone": "^0.5.27", "ngreact": "^0.5.1", "nock": "12.0.3", - "node-fetch": "^2.6.0", + "node-fetch": "^2.6.1", "nodemailer": "^4.7.0", "object-hash": "^1.3.1", "object-path-immutable": "^3.1.1", diff --git a/x-pack/plugins/apm/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/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/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/reporting/server/lib/layouts/preserve_layout.css b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css index 12ac5b27c7a4a..35590df90fbb9 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css @@ -36,7 +36,7 @@ filter-bar, /* hide unusable controls */ discover-app .dscTimechart, discover-app .dscSidebar__container, -discover-app .kbnCollapsibleSidebar__collapseButton, +discover-app .dscCollapsibleSidebar__collapseButton, discover-app .discover-table-footer { display: none; } diff --git a/x-pack/plugins/reporting/server/lib/layouts/print.css b/x-pack/plugins/reporting/server/lib/layouts/print.css index 9b07e3c923138..3ff39974536d2 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print.css +++ b/x-pack/plugins/reporting/server/lib/layouts/print.css @@ -35,7 +35,7 @@ filter-bar, /* hide unusable controls */ discover-app .dscTimechart, discover-app .dscSidebar__container, -discover-app .kbnCollapsibleSidebar__collapseButton, +discover-app .dscCollapsibleSidebar__collapseButton, discover-app .discover-table-footer { display: none; } diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 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/cypress/integration/timeline_attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts index b4aef4b6848a1..3862a89a7d833 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts @@ -34,7 +34,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id%27'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -46,7 +46,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id%27'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -68,7 +68,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(ADD_COMMENT_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id%27'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 62f360df90192..40320ed794203 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -21,6 +21,7 @@ import { import { AdministrationSubTab } from '../types'; import { appendSearch } from '../../common/components/link_to/helpers'; import { EndpointIndexUIQueryParams } from '../pages/endpoint_hosts/types'; +import { TrustedAppsUrlParams } from '../pages/trusted_apps/types'; // Taken from: https://github.com/microsoft/TypeScript/issues/12936#issuecomment-559034150 type ExactKeys = Exclude extends never ? T1 : never; @@ -89,18 +90,16 @@ export const getPolicyDetailPath = (policyId: string, search?: string) => { })}${appendSearch(search)}`; }; -interface ListPaginationParams { - page_index: number; - page_size: number; -} - -const isDefaultOrMissing = (value: number | undefined, defaultValue: number) => { +const isDefaultOrMissing = ( + value: number | string | undefined, + defaultValue: number | undefined +) => { return value === undefined || value === defaultValue; }; const normalizeListPaginationParams = ( - params?: Partial -): Partial => { + params?: Partial +): Partial => { if (params) { return { ...(!isDefaultOrMissing(params.page_index, MANAGEMENT_DEFAULT_PAGE) @@ -109,13 +108,19 @@ const normalizeListPaginationParams = ( ...(!isDefaultOrMissing(params.page_size, MANAGEMENT_DEFAULT_PAGE_SIZE) ? { page_size: params.page_size } : {}), + ...(!isDefaultOrMissing(params.show, undefined) ? { show: params.show } : {}), }; } else { return {}; } }; -const extractFirstParamValue = (query: querystring.ParsedUrlQuery, key: string): string => { +/** + * Given an object with url params, and a given key, return back only the first param value (case multiples were defined) + * @param query + * @param key + */ +export const extractFirstParamValue = (query: querystring.ParsedUrlQuery, key: string): string => { const value = query[key]; return Array.isArray(value) ? value[value.length - 1] : value; @@ -135,12 +140,12 @@ const extractPageSize = (query: querystring.ParsedUrlQuery): number => { export const extractListPaginationParams = ( query: querystring.ParsedUrlQuery -): ListPaginationParams => ({ +): TrustedAppsUrlParams => ({ page_index: extractPageIndex(query), page_size: extractPageSize(query), }); -export const getTrustedAppsListPath = (params?: Partial): string => { +export const getTrustedAppsListPath = (params?: Partial): string => { const path = generatePath(MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, { tabName: AdministrationSubTab.trustedApps, }); diff --git a/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx b/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx index 3df525b4d59d6..372916581b35d 100644 --- a/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx @@ -5,6 +5,7 @@ */ import React, { FC, memo } from 'react'; import { EuiPanel, EuiSpacer, CommonProps } from '@elastic/eui'; +import styled from 'styled-components'; import { SecurityPageName } from '../../../common/constants'; import { WrapperPage } from '../../common/components/wrapper_page'; import { HeaderPage } from '../../common/components/header_page'; @@ -14,6 +15,13 @@ import { AdministrationSubTab } from '../types'; import { ENDPOINTS_TAB, TRUSTED_APPS_TAB, BETA_BADGE_LABEL } from '../common/translations'; import { getEndpointListPath, getTrustedAppsListPath } from '../common/routing'; +/** Ensure that all flyouts z-index in Administation area show the flyout header */ +const EuiPanelStyled = styled(EuiPanel)` + .euiFlyout { + z-index: ${({ theme }) => theme.eui.euiZNavigation + 1}; + } +`; + interface AdministrationListPageProps { beta: boolean; title: React.ReactNode; @@ -54,7 +62,7 @@ export const AdministrationListPage: FC - {children} + {children} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts index c5363a5ae9522..3b05afb975d47 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts @@ -11,6 +11,7 @@ import { HostPolicyResponse, HostResultList, HostStatus, + MetadataQueryStrategyVersions, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { @@ -49,6 +50,7 @@ export const mockEndpointResultList: (options?: { hosts.push({ metadata: generator.generateHostMetadata(), host_status: HostStatus.ERROR, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }); } const mock: HostResultList = { @@ -56,6 +58,7 @@ export const mockEndpointResultList: (options?: { total, request_page_size: requestPageSize, request_page_index: requestPageIndex, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; return mock; }; @@ -67,6 +70,7 @@ export const mockEndpointDetailsApiResult = (): HostInfo => { return { metadata: generator.generateHostMetadata(), host_status: HostStatus.ERROR, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; }; @@ -103,6 +107,7 @@ const endpointListApiPathHandlerMocks = ({ request_page_size: 10, request_page_index: 0, total: endpointsResults?.length || 0, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 2bdf17b806079..bd8344f41fe3a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -20,6 +20,7 @@ import { HostPolicyResponseActionStatus, HostPolicyResponseAppliedAction, HostStatus, + MetadataQueryStrategyVersions, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants'; @@ -131,6 +132,7 @@ describe('when on the list page', () => { hostListData[index] = { metadata: hostListData[index].metadata, host_status: status, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; } ); @@ -301,6 +303,8 @@ describe('when on the list page', () => { // eslint-disable-next-line @typescript-eslint/naming-convention host_status, metadata: { host, ...details }, + // eslint-disable-next-line @typescript-eslint/naming-convention + query_strategy_version, } = mockEndpointDetailsApiResult(); hostDetails = { @@ -312,6 +316,7 @@ describe('when on the list page', () => { id: '1', }, }, + query_strategy_version, }; agentId = hostDetails.metadata.elastic.agent.id; @@ -681,6 +686,7 @@ describe('when on the list page', () => { hostInfo = { host_status: hosts[0].host_status, metadata: hosts[0].metadata, + query_strategy_version: MetadataQueryStrategyVersions.VERSION_2, }; const packagePolicy = docGenerator.generatePolicyPackagePolicy(); packagePolicy.id = hosts[0].metadata.Endpoint.policy.applied.id; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 9308c137cfb9c..a3c5911aa3a86 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -5,14 +5,20 @@ */ import { HttpStart } from 'kibana/public'; -import { TRUSTED_APPS_LIST_API } from '../../../../../common/endpoint/constants'; +import { + TRUSTED_APPS_CREATE_API, + TRUSTED_APPS_LIST_API, +} from '../../../../../common/endpoint/constants'; import { GetTrustedListAppsResponse, GetTrustedAppsListRequest, + PostTrustedAppCreateRequest, + PostTrustedAppCreateResponse, } from '../../../../../common/endpoint/types/trusted_apps'; export interface TrustedAppsService { getTrustedAppsList(request: GetTrustedAppsListRequest): Promise; + createTrustedApp(request: PostTrustedAppCreateRequest): Promise; } export class TrustedAppsHttpService implements TrustedAppsService { @@ -23,4 +29,10 @@ export class TrustedAppsHttpService implements TrustedAppsService { query: request, }); } + + async createTrustedApp(request: PostTrustedAppCreateRequest) { + return this.http.post(TRUSTED_APPS_CREATE_API, { + body: JSON.stringify(request), + }); + } } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts index 23f4cfd576c56..071557ec1a815 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; +import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; import { AsyncResourceState } from '.'; +import { TrustedAppsUrlParams } from '../types'; +import { ServerApiError } from '../../../../common/types'; export interface PaginationInfo { index: number; @@ -18,10 +20,34 @@ export interface TrustedAppsListData { paginationInfo: PaginationInfo; } +/** Store State when an API request has been sent to create a new trusted app entry */ +export interface TrustedAppCreatePending { + type: 'pending'; + data: NewTrustedApp; +} + +/** Store State when creation of a new Trusted APP entry was successful */ +export interface TrustedAppCreateSuccess { + type: 'success'; + data: TrustedApp; +} + +/** Store State when creation of a new Trusted App Entry failed */ +export interface TrustedAppCreateFailure { + type: 'failure'; + data: ServerApiError; +} + export interface TrustedAppsListPageState { listView: { currentListResourceState: AsyncResourceState; currentPaginationInfo: PaginationInfo; + show: TrustedAppsUrlParams['show'] | undefined; }; + createView: + | undefined + | TrustedAppCreatePending + | TrustedAppCreateSuccess + | TrustedAppCreateFailure; active: boolean; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts new file mode 100644 index 0000000000000..1e8e0bc042b86 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + TrustedAppCreatePending, + TrustedAppsListPageState, + TrustedAppCreateFailure, + TrustedAppCreateSuccess, +} from './trusted_apps_list_page_state'; +import { + Immutable, + NewTrustedApp, + WindowsConditionEntry, +} from '../../../../../common/endpoint/types'; +import { TRUSTED_APPS_SUPPORTED_OS_TYPES } from '../../../../../common/endpoint/constants'; + +type CreateViewPossibleStates = + | TrustedAppsListPageState['createView'] + | Immutable; + +export const isTrustedAppCreatePendingState = ( + data: CreateViewPossibleStates +): data is TrustedAppCreatePending => { + return data?.type === 'pending'; +}; + +export const isTrustedAppCreateSuccessState = ( + data: CreateViewPossibleStates +): data is TrustedAppCreateSuccess => { + return data?.type === 'success'; +}; + +export const isTrustedAppCreateFailureState = ( + data: CreateViewPossibleStates +): data is TrustedAppCreateFailure => { + return data?.type === 'failure'; +}; + +export const isWindowsTrustedApp = ( + trustedApp: T +): trustedApp is T & { os: 'windows' } => { + return trustedApp.os === 'windows'; +}; + +export const isWindowsTrustedAppCondition = (condition: { + field: string; +}): condition is WindowsConditionEntry => { + return condition.field === 'process.code_signature' || true; +}; + +export const isTrustedAppSupportedOs = (os: string): os is NewTrustedApp['os'] => + TRUSTED_APPS_SUPPORTED_OS_TYPES.includes(os); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts index 2154a0eca462e..3a43ffe58262c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AsyncResourceState, TrustedAppsListData } from '../state'; +import { + AsyncResourceState, + TrustedAppCreateFailure, + TrustedAppCreatePending, + TrustedAppCreateSuccess, + TrustedAppsListData, +} from '../state'; export interface TrustedAppsListResourceStateChanged { type: 'trustedAppsListResourceStateChanged'; @@ -13,4 +19,23 @@ export interface TrustedAppsListResourceStateChanged { }; } -export type TrustedAppsPageAction = TrustedAppsListResourceStateChanged; +export interface UserClickedSaveNewTrustedAppButton { + type: 'userClickedSaveNewTrustedAppButton'; + payload: TrustedAppCreatePending; +} + +export interface ServerReturnedCreateTrustedAppSuccess { + type: 'serverReturnedCreateTrustedAppSuccess'; + payload: TrustedAppCreateSuccess; +} + +export interface ServerReturnedCreateTrustedAppFailure { + type: 'serverReturnedCreateTrustedAppFailure'; + payload: TrustedAppCreateFailure; +} + +export type TrustedAppsPageAction = + | TrustedAppsListResourceStateChanged + | UserClickedSaveNewTrustedAppButton + | ServerReturnedCreateTrustedAppSuccess + | ServerReturnedCreateTrustedAppFailure; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index c5abaae473486..e5f00ee0ccf81 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -31,6 +31,7 @@ const createGetTrustedListAppsResponse = (pagination: PaginationInfo, totalItems const createTrustedAppsServiceMock = (): jest.Mocked => ({ getTrustedAppsList: jest.fn(), + createTrustedApp: jest.fn(), }); const createStoreSetup = (trustedAppsService: TrustedAppsService) => { @@ -70,6 +71,7 @@ describe('middleware', () => { expect(store.getState()).toStrictEqual({ listView: createLoadingListViewWithPagination(pagination), active: true, + createView: undefined, }); await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); @@ -77,6 +79,7 @@ describe('middleware', () => { expect(store.getState()).toStrictEqual({ listView: createLoadedListViewWithPagination(pagination, pagination, 500), active: true, + createView: undefined, }); }); @@ -99,6 +102,7 @@ describe('middleware', () => { expect(store.getState()).toStrictEqual({ listView: createLoadedListViewWithPagination(pagination, pagination, 500), active: true, + createView: undefined, }); }); @@ -118,6 +122,7 @@ describe('middleware', () => { createServerApiError('Internal Server Error') ), active: true, + createView: undefined, }); const infiniteLoopTest = async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index 31c301b8dbd2b..bf9cacff5caf0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Immutable } from '../../../../../common/endpoint/types'; +import { Immutable, PostTrustedAppCreateRequest } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { ImmutableMiddleware, @@ -28,6 +28,8 @@ import { getLastLoadedListResourceState, getListCurrentPageIndex, getListCurrentPageSize, + getTrustedAppCreateData, + isCreatePending, needsRefreshOfListData, } from './selectors'; @@ -81,6 +83,38 @@ const refreshList = async ( } }; +const createTrustedApp = async ( + store: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const { dispatch, getState } = store; + + if (isCreatePending(getState())) { + try { + const newTrustedApp = getTrustedAppCreateData(getState()); + const createdTrustedApp = ( + await trustedAppsService.createTrustedApp(newTrustedApp as PostTrustedAppCreateRequest) + ).data; + dispatch({ + type: 'serverReturnedCreateTrustedAppSuccess', + payload: { + type: 'success', + data: createdTrustedApp, + }, + }); + refreshList(store, trustedAppsService); + } catch (error) { + dispatch({ + type: 'serverReturnedCreateTrustedAppFailure', + payload: { + type: 'failure', + data: error.body || error, + }, + }); + } + } +}; + export const createTrustedAppsPageMiddleware = ( trustedAppsService: TrustedAppsService ): ImmutableMiddleware => { @@ -91,6 +125,10 @@ export const createTrustedAppsPageMiddleware = ( if (action.type === 'userChangedUrl' && needsRefreshOfListData(store.getState())) { await refreshList(store, trustedAppsService); } + + if (action.type === 'userClickedSaveNewTrustedAppButton') { + createTrustedApp(store, trustedAppsService); + } }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts index 34325e0cf1398..76dd4b48e63d2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts @@ -26,6 +26,7 @@ describe('reducer', () => { currentPaginationInfo: { index: 5, size: 50 }, }, active: true, + createView: undefined, }); }); @@ -67,7 +68,7 @@ describe('reducer', () => { it('makes page state inactive and resets list to uninitialised state when navigating away', () => { const result = trustedAppsPageReducer( - { listView: createLoadedListViewWithPagination(), active: true }, + { listView: createLoadedListViewWithPagination(), active: true, createView: undefined }, createUserChangedUrlAction('/endpoints') ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts index 4fdc6f90ef40c..d824a6e95c8d5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts @@ -11,14 +11,19 @@ import { ImmutableReducer } from '../../../../common/store'; import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; import { UserChangedUrl } from '../../../../common/store/routing/action'; import { AppAction } from '../../../../common/store/actions'; -import { extractListPaginationParams } from '../../../common/routing'; +import { extractFirstParamValue, extractListPaginationParams } from '../../../common/routing'; import { MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE, } from '../../../common/constants'; -import { TrustedAppsListResourceStateChanged } from './action'; +import { + ServerReturnedCreateTrustedAppFailure, + ServerReturnedCreateTrustedAppSuccess, + TrustedAppsListResourceStateChanged, + UserClickedSaveNewTrustedAppButton, +} from './action'; import { TrustedAppsListPageState } from '../state'; type StateReducer = ImmutableReducer; @@ -51,7 +56,10 @@ const trustedAppsListResourceStateChanged: CaseReducer = (state, action) => { if (isTrustedAppsPageLocation(action.payload)) { - const paginationParams = extractListPaginationParams(parse(action.payload.search.slice(1))); + const parsedUrlsParams = parse(action.payload.search.slice(1)); + const paginationParams = extractListPaginationParams(parsedUrlsParams); + const show = + extractFirstParamValue(parsedUrlsParams, 'show') === 'create' ? 'create' : undefined; return { ...state, @@ -61,7 +69,9 @@ const userChangedUrl: CaseReducer = (state, action) => { index: paginationParams.page_index, size: paginationParams.page_size, }, + show, }, + createView: show ? state.createView : undefined, active: true, }; } else { @@ -69,6 +79,17 @@ const userChangedUrl: CaseReducer = (state, action) => { } }; +const trustedAppsCreateResourceChanged: CaseReducer< + | UserClickedSaveNewTrustedAppButton + | ServerReturnedCreateTrustedAppFailure + | ServerReturnedCreateTrustedAppSuccess +> = (state, action) => { + return { + ...state, + createView: action.payload, + }; +}; + export const initialTrustedAppsPageState: TrustedAppsListPageState = { listView: { currentListResourceState: { type: 'UninitialisedResourceState' }, @@ -76,7 +97,9 @@ export const initialTrustedAppsPageState: TrustedAppsListPageState = { index: MANAGEMENT_DEFAULT_PAGE, size: MANAGEMENT_DEFAULT_PAGE_SIZE, }, + show: undefined, }, + createView: undefined, active: false, }; @@ -90,6 +113,11 @@ export const trustedAppsPageReducer: StateReducer = ( case 'userChangedUrl': return userChangedUrl(state, action); + + case 'userClickedSaveNewTrustedAppButton': + case 'serverReturnedCreateTrustedAppSuccess': + case 'serverReturnedCreateTrustedAppFailure': + return trustedAppsCreateResourceChanged(state, action); } return state; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts index a969e2dee4773..453afa1befa6b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts @@ -30,33 +30,41 @@ import { describe('selectors', () => { describe('needsRefreshOfListData()', () => { it('returns false for outdated resource state and inactive state', () => { - expect(needsRefreshOfListData({ listView: createDefaultListView(), active: false })).toBe( - false - ); + expect( + needsRefreshOfListData({ + listView: createDefaultListView(), + active: false, + createView: undefined, + }) + ).toBe(false); }); it('returns true for outdated resource state and active state', () => { - expect(needsRefreshOfListData({ listView: createDefaultListView(), active: true })).toBe( - true - ); + expect( + needsRefreshOfListData({ + listView: createDefaultListView(), + active: true, + createView: undefined, + }) + ).toBe(true); }); it('returns true when current loaded page index is outdated', () => { const listView = createLoadedListViewWithPagination({ index: 1, size: 20 }); - expect(needsRefreshOfListData({ listView, active: true })).toBe(true); + expect(needsRefreshOfListData({ listView, active: true, createView: undefined })).toBe(true); }); it('returns true when current loaded page size is outdated', () => { const listView = createLoadedListViewWithPagination({ index: 0, size: 50 }); - expect(needsRefreshOfListData({ listView, active: true })).toBe(true); + expect(needsRefreshOfListData({ listView, active: true, createView: undefined })).toBe(true); }); it('returns false when current loaded data is up to date', () => { const listView = createLoadedListViewWithPagination(); - expect(needsRefreshOfListData({ listView, active: true })).toBe(false); + expect(needsRefreshOfListData({ listView, active: true, createView: undefined })).toBe(false); }); }); @@ -64,9 +72,9 @@ describe('selectors', () => { it('returns current list resource state', () => { const listView = createDefaultListView(); - expect(getCurrentListResourceState({ listView, active: false })).toStrictEqual( - createUninitialisedResourceState() - ); + expect( + getCurrentListResourceState({ listView, active: false, createView: undefined }) + ).toStrictEqual(createUninitialisedResourceState()); }); }); @@ -78,17 +86,20 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getLastLoadedListResourceState({ listView, active: false })).toStrictEqual( - createListLoadedResourceState(createDefaultPaginationInfo(), 200) - ); + expect( + getLastLoadedListResourceState({ listView, active: false, createView: undefined }) + ).toStrictEqual(createListLoadedResourceState(createDefaultPaginationInfo(), 200)); }); }); describe('getListItems()', () => { it('returns empty list when no valid data loaded', () => { - expect(getListItems({ listView: createDefaultListView(), active: false })).toStrictEqual([]); + expect( + getListItems({ listView: createDefaultListView(), active: false, createView: undefined }) + ).toStrictEqual([]); }); it('returns last loaded list items', () => { @@ -98,9 +109,10 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getListItems({ listView, active: false })).toStrictEqual( + expect(getListItems({ listView, active: false, createView: undefined })).toStrictEqual( createSampleTrustedApps(createDefaultPaginationInfo()) ); }); @@ -108,7 +120,13 @@ describe('selectors', () => { describe('getListTotalItemsCount()', () => { it('returns 0 when no valid data loaded', () => { - expect(getListTotalItemsCount({ listView: createDefaultListView(), active: false })).toBe(0); + expect( + getListTotalItemsCount({ + listView: createDefaultListView(), + active: false, + createView: undefined, + }) + ).toBe(0); }); it('returns last loaded total items count', () => { @@ -118,21 +136,34 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getListTotalItemsCount({ listView, active: false })).toBe(200); + expect(getListTotalItemsCount({ listView, active: false, createView: undefined })).toBe(200); }); }); describe('getListCurrentPageIndex()', () => { it('returns page index', () => { - expect(getListCurrentPageIndex({ listView: createDefaultListView(), active: false })).toBe(0); + expect( + getListCurrentPageIndex({ + listView: createDefaultListView(), + active: false, + createView: undefined, + }) + ).toBe(0); }); }); describe('getListCurrentPageSize()', () => { it('returns page index', () => { - expect(getListCurrentPageSize({ listView: createDefaultListView(), active: false })).toBe(20); + expect( + getListCurrentPageSize({ + listView: createDefaultListView(), + active: false, + createView: undefined, + }) + ).toBe(20); }); }); @@ -144,24 +175,32 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getListErrorMessage({ listView, active: false })).toBeUndefined(); + expect( + getListErrorMessage({ listView, active: false, createView: undefined }) + ).toBeUndefined(); }); it('returns message when not in failed state', () => { const listView = { currentListResourceState: createListFailedResourceState('Internal Server Error'), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getListErrorMessage({ listView, active: false })).toBe('Internal Server Error'); + expect(getListErrorMessage({ listView, active: false, createView: undefined })).toBe( + 'Internal Server Error' + ); }); }); describe('isListLoading()', () => { it('returns false when no loading is happening', () => { - expect(isListLoading({ listView: createDefaultListView(), active: false })).toBe(false); + expect( + isListLoading({ listView: createDefaultListView(), active: false, createView: undefined }) + ).toBe(false); }); it('returns true when loading is in progress', () => { @@ -171,9 +210,10 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(isListLoading({ listView, active: false })).toBe(true); + expect(isListLoading({ listView, active: false, createView: undefined })).toBe(true); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index 6fde779ac1cce..f074b21f79f4e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Immutable, TrustedApp } from '../../../../../common/endpoint/types'; +import { createSelector } from 'reselect'; +import { Immutable, NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types'; import { AsyncResourceState, @@ -14,9 +15,16 @@ import { isOutdatedResourceState, LoadedResourceState, PaginationInfo, + TrustedAppCreateFailure, TrustedAppsListData, TrustedAppsListPageState, } from '../state'; +import { TrustedAppsUrlParams } from '../types'; +import { + isTrustedAppCreateFailureState, + isTrustedAppCreatePendingState, + isTrustedAppCreateSuccessState, +} from '../state/type_guards'; const pageInfosEqual = (pageInfo1: PaginationInfo, pageInfo2: PaginationInfo): boolean => pageInfo1.index === pageInfo2.index && pageInfo1.size === pageInfo2.size; @@ -65,6 +73,27 @@ export const getListTotalItemsCount = (state: Immutable +) => TrustedAppsListPageState['listView']['show'] = (state) => { + return state.listView.show; +}; + +export const getListUrlSearchParams: ( + state: Immutable +) => TrustedAppsUrlParams = createSelector( + getListCurrentPageIndex, + getListCurrentPageSize, + getListCurrentShowValue, + (pageIndex, pageSize, showValue) => { + return { + page_index: pageIndex, + page_size: pageSize, + show: showValue, + }; + } +); + export const getListErrorMessage = ( state: Immutable ): string | undefined => { @@ -74,3 +103,27 @@ export const getListErrorMessage = ( export const isListLoading = (state: Immutable): boolean => { return isLoadingResourceState(state.listView.currentListResourceState); }; + +export const isCreatePending: (state: Immutable) => boolean = ({ + createView, +}) => { + return isTrustedAppCreatePendingState(createView); +}; + +export const getTrustedAppCreateData: ( + state: Immutable +) => undefined | Immutable = ({ createView }) => { + return (isTrustedAppCreatePendingState(createView) && createView.data) || undefined; +}; + +export const getApiCreateErrors: ( + state: Immutable +) => undefined | TrustedAppCreateFailure['data'] = ({ createView }) => { + return (isTrustedAppCreateFailureState(createView) && createView.data) || undefined; +}; + +export const wasCreateSuccessful: (state: Immutable) => boolean = ({ + createView, +}) => { + return isTrustedAppCreateSuccessState(createView); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index fab059a422a2a..70e4e1e685b01 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -21,6 +21,7 @@ import { } from '../state'; import { TrustedAppsListResourceStateChanged } from '../store/action'; +import { initialTrustedAppsPageState } from '../store/reducer'; const OS_LIST: Array = ['windows', 'macos', 'linux']; @@ -93,6 +94,7 @@ export const createListComplexLoadingResourceState = ( export const createDefaultPaginationInfo = () => ({ index: 0, size: 20 }); export const createDefaultListView = () => ({ + ...initialTrustedAppsPageState.listView, currentListResourceState: createUninitialisedResourceState(), currentPaginationInfo: createDefaultPaginationInfo(), }); @@ -101,6 +103,7 @@ export const createLoadingListViewWithPagination = ( currentPaginationInfo: PaginationInfo, previousState: StaleResourceState = createUninitialisedResourceState() ): TrustedAppsListPageState['listView'] => ({ + ...initialTrustedAppsPageState.listView, currentListResourceState: { type: 'LoadingResourceState', previousState }, currentPaginationInfo, }); @@ -110,6 +113,7 @@ export const createLoadedListViewWithPagination = ( currentPaginationInfo: PaginationInfo = createDefaultPaginationInfo(), totalItemsCount: number = 200 ): TrustedAppsListPageState['listView'] => ({ + ...initialTrustedAppsPageState.listView, currentListResourceState: createListLoadedResourceState(paginationInfo, totalItemsCount), currentPaginationInfo, }); @@ -119,6 +123,7 @@ export const createFailedListViewWithPagination = ( error: ServerApiError, lastLoadedState?: LoadedResourceState ): TrustedAppsListPageState['listView'] => ({ + ...initialTrustedAppsPageState.listView, currentListResourceState: { type: 'FailedResourceState', error, lastLoadedState }, currentPaginationInfo, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts new file mode 100644 index 0000000000000..4d59cd7913a0c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface TrustedAppsUrlParams { + page_index: number; + page_size: number; + show?: 'create'; +} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap index d6e9aee108cf6..642e86059ed6e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap @@ -1,23 +1,955 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`TrustedAppsPage rendering 1`] = ` - +Object { + "asFragment": [Function], + "baseElement": .c0 { + padding: 24px; +} + +.c0.siemWrapperPage--restrictWidthDefault, +.c0.siemWrapperPage--restrictWidthCustom { + box-sizing: content-box; + margin: 0 auto; +} + +.c0.siemWrapperPage--restrictWidthDefault { + max-width: 1000px; +} + +.c0.siemWrapperPage--fullHeight { + height: 100%; +} + +.c0.siemWrapperPage--withTimeline { + padding-right: 70px; +} + +.c0.siemWrapperPage--noPadding { + padding: 0; +} + +.c4 { + margin-top: 8px; +} + +.c4 .siemSubtitle__item { + color: #6a717d; + font-size: 12px; + line-height: 1.5; +} + +.c3 { + vertical-align: middle; +} + +.c1 { + margin-bottom: 24px; +} + +.c2 { + display: block; +} + +.c5 .euiFlyout { + z-index: 4001; +} + +@media only screen and (min-width:575px) { + .c4 .siemSubtitle__item { + display: inline-block; + margin-right: 16px; } - title={ - + + .c4 .siemSubtitle__item:last-child { + margin-right: 0; } +} + + +
+
+
+
+
+

+ Trusted Applications + + + Beta + +

+
+
+ View and configure trusted applications +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ + No items found + +
+
+
+
+
+
+
+ , + "container":
+
+
+
+
+

+ Trusted Applications + + + Beta + +

+
+
+ View and configure trusted applications +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ + No items found + +
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`TrustedAppsPage when the Add Trusted App button is clicked should display create form 1`] = ` +@media only screen and (min-width:575px) { + +} + +
- - +
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: Windows, is selected + + +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: Hash, is selected + + +
+ + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+